using System.Buffers.Binary;
using System.IO.Compression;
using System.Numerics;
using System.Reflection;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace XFEEDToolBox.Core.Rendering;
public sealed record EliteMeshDraw(int MetadataOffset, int IndexCount, int VertexCount, int IndexOffset, int VertexOffset);
public sealed record EliteModelReference(string ShipType, string ResourceName, string ResourceHash, string SourceSha256,
int DeflateOffset, int InflatedBytes, int GpuOffset, int GpuBytes, int VertexBufferOffset, int VertexStride, EliteMeshDraw[] Meshes);
public sealed record EliteModelIndex(string GameSha256, int PublicKeyRva, int PublicKeyBytes, EliteModelReference[] Models);
/// <summary>Reads the installed game's display meshes without loading or changing its executable.</summary>
public sealed class EliteLocalModels
{
public static EliteModelIndex Reference { get; } = ReadReference();
private readonly string root;
private readonly BigInteger modulus, exponent;
private readonly int rsaBytes;
public static string Normalize(string type) => new(type.ToLowerInvariant().Where(char.IsAsciiLetterOrDigit).ToArray());
public EliteLocalModels(string gameExecutable)
{
root = Path.GetDirectoryName(Path.GetFullPath(gameExecutable))!;
using var file = File.OpenRead(gameExecutable);
if (!Convert.ToHexString(SHA256.HashData(file)).Equals(Reference.GameSha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("游戏版本已有变化,需要更新本地模型资源索引。");
file.Position = 0;
using var pe = new PEReader(file);
var key = pe.GetSectionData(Reference.PublicKeyRva).GetContent(0, Reference.PublicKeyBytes).ToArray();
using var rsa = RSA.Create(); rsa.ImportRSAPublicKey(key, out var consumed);
if (consumed != key.Length) throw new InvalidDataException("游戏模型资源公钥格式不符。");
var parameters = rsa.ExportParameters(false);
modulus = new(parameters.Modulus!, isUnsigned: true, isBigEndian: true);
exponent = new(parameters.Exponent!, isUnsigned: true, isBigEndian: true);
rsaBytes = parameters.Modulus!.Length;
}
public ShipMesh? Read(string shipType)
{
var model = Reference.Models.FirstOrDefault(m => Normalize(m.ShipType) == Normalize(shipType));
if (model is null) return null;
var bucket = Convert.ToInt32(model.ResourceHash[..1], 16).ToString("x2");
var path = Path.Combine(root, "Win64", bucket, model.ResourceHash);
var info = new FileInfo(path);
if (!info.Exists || info.Length is < 262 or > 128 * 1024 * 1024) throw new InvalidDataException("本地飞船模型资源缺失或超出大小限制。");
var archive = File.ReadAllBytes(path);
var hash = Convert.ToHexString(SHA256.HashData(archive));
if (!hash.Equals(model.SourceSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("本地模型资源已有变化,需要更新模型索引。");
var resource = DecodeArchive(archive, model.ResourceHash);
if (!resource.AsSpan(0, 4).SequenceEqual("FRES"u8) || model.DeflateOffset < 144 || model.DeflateOffset >= resource.Length)
throw new InvalidDataException("模型资源头无效。");
using var compressed = new ZLibStream(new MemoryStream(resource, model.DeflateOffset, resource.Length - model.DeflateOffset, false), CompressionMode.Decompress);
var data = new byte[model.InflatedBytes]; compressed.ReadExactly(data);
if (compressed.ReadByte() != -1) throw new InvalidDataException("模型数据长度与索引不符。");
if (model.GpuOffset < 0 || model.GpuBytes <= 0 || (long)model.GpuOffset + model.GpuBytes > data.Length) throw new InvalidDataException("模型网格数据越界。");
var triangles = new List<MeshVertex>(model.Meshes.Sum(m => m.IndexCount));
foreach (var draw in model.Meshes)
{
if (draw.IndexCount <= 0 || draw.IndexCount % 3 != 0 || draw.VertexCount is <= 0 or > 65536 ||
ReadInt(data, draw.MetadataOffset) != draw.IndexCount || ReadInt(data, draw.MetadataOffset + 4) != draw.VertexCount)
throw new InvalidDataException("船体绘制记录与资源索引不符。");
var vertices = new MeshVertex[draw.VertexCount];
var vertexStart = checked(model.GpuOffset + model.VertexBufferOffset + draw.VertexOffset);
if (vertexStart < model.GpuOffset || (long)vertexStart + vertices.Length * model.VertexStride > (long)model.GpuOffset + model.GpuBytes) throw new InvalidDataException("船体顶点数据越界。");
for (var i = 0; i < vertices.Length; i++)
{
var bytes = data.AsSpan(vertexStart + i * model.VertexStride, model.VertexStride);
var normal = new Vector3(bytes[8] / 127f - 1, bytes[9] / 127f - 1, bytes[10] / 127f - 1);
if (normal.LengthSquared() < .01f) normal = Vector3.UnitY; else normal = Vector3.Normalize(normal);
vertices[i] = new(DecodePosition(bytes), normal);
}
var indexStart = checked(model.GpuOffset + draw.IndexOffset);
if (indexStart < model.GpuOffset || (long)indexStart + draw.IndexCount * 2 > (long)model.GpuOffset + model.GpuBytes) throw new InvalidDataException("船体索引数据越界。");
for (var i = 0; i < draw.IndexCount; i++)
{
var index = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(indexStart + i * 2, 2));
if (index >= vertices.Length) throw new InvalidDataException("船体顶点索引越界。");
triangles.Add(vertices[index]);
}
}
return ShipMeshReader.Create(triangles.ToArray(), path + "#" + model.ResourceName, hash);
}
internal byte[] ReadDisplayResource(string resourceHash,string expectedHash)
{
if(resourceHash.Length!=40||!resourceHash.All(char.IsAsciiHexDigit)||expectedHash.Length!=64)throw new InvalidDataException("本地显示资源索引无效。");
var path=Path.Combine(root,"Win64",Convert.ToInt32(resourceHash[..1],16).ToString("x2"),resourceHash);
var file=new FileInfo(path);
if(!file.Exists||file.Length is <262 or >4*1024*1024)throw new InvalidDataException("本地显示资源缺失或过大。");
var data=File.ReadAllBytes(path);
if(!Convert.ToHexString(SHA256.HashData(data)).Equals(expectedHash,StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("本地显示资源版本不匹配。");
return DecodeArchive(data,resourceHash);
}
// Matches the installed game's PACKEDVERTEXDATAA vertex shader. The scale exponent is per vertex.
public static Vector3 DecodePosition(ReadOnlySpan<byte> vertex)
{
if (vertex.Length < 12) throw new InvalidDataException("船体压缩顶点不完整。");
var packed = BinaryPrimitives.ReadUInt64LittleEndian(vertex);
var power = vertex[11] & 127;
if (power == 64)
{
var radius = Math.Pow(2, (packed >> 48) * (16.0 / 65535)) - 1;
return new((float)((packed & 65535) * (2.0 / 65535) - 1) * (float)radius,
(float)(((packed >> 16) & 65535) * (2.0 / 65535) - 1) * (float)radius,
(float)(((packed >> 32) & 65535) * (2.0 / 65535) - 1) * (float)radius);
}
if (power > 19) throw new InvalidDataException("未知的船体坐标压缩参数。");
var scale = 1.0 / ((1 << (20 - power)) - 1); var bias = 1 << power;
return new((float)((packed & 0x1fffff) * scale - bias),
(float)(((packed >> 21) & 0x1fffff) * scale - bias), (float)(((packed >> 42) & 0x1fffff) * scale - bias));
}
private byte[] DecodeArchive(byte[] archive, string resourceHash)
{
if (!archive.AsSpan(0, 6).SequenceEqual(new byte[] { 70, 82, 69, 65, 0, 0 })) throw new InvalidDataException("模型资源封装版本不符。");
// FREA uses a public RSA signature block for its header; no account or license state is accessed.
var signature = new BigInteger(archive.AsSpan(6, rsaBytes), isUnsigned: true, isBigEndian: true);
var blockBytes = BigInteger.ModPow(signature, exponent, modulus).ToByteArray(isUnsigned: true, isBigEndian: true);
var block = new byte[rsaBytes]; blockBytes.CopyTo(block.AsSpan(rsaBytes - blockBytes.Length));
if (block[0] != 0 || block[1] != 1) throw new InvalidDataException("模型资源签名头无效。");
var separator = 2;
while (separator < block.Length && block[separator] == 255) separator++;
if (separator < 10 || separator >= block.Length || block[separator] != 0) throw new InvalidDataException("模型资源签名填充无效。");
var message = block.AsSpan(separator + 1);
if (message.Length < 40) throw new InvalidDataException("模型资源签名块不完整。");
var size = BinaryPrimitives.ReadInt32LittleEndian(message);
if (size is < 144 or > 128 * 1024 * 1024) throw new InvalidDataException("模型资源声明的长度无效。");
var mask = Encoding.ASCII.GetBytes(resourceHash.ToLowerInvariant());
var key = message.Slice(4, 32).ToArray();
for (var i = 0; i < key.Length; i++) key[i] ^= mask[i % mask.Length];
using var aes = Aes.Create(); aes.Key = key;
var tail = aes.DecryptEcb(archive.AsSpan(6 + rsaBytes), PaddingMode.None);
var prefix = message[36..];
if (size > prefix.Length + tail.Length) throw new InvalidDataException("模型资源长度不足。");
var result = new byte[size]; prefix[..Math.Min(size, prefix.Length)].CopyTo(result);
if (size > prefix.Length) tail.AsSpan(0, size - prefix.Length).CopyTo(result.AsSpan(prefix.Length));
return result;
}
private static int ReadInt(byte[] data, int offset) => offset >= 0 && offset <= data.Length - 4 ? BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4)) : -1;
private static EliteModelIndex ReadReference()
{
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("XFEEDToolBox.Core.Rendering.local-model-reference.json")!;
return JsonSerializer.Deserialize<EliteModelIndex>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
}
using System.Buffers.Binary;
using System.IO.Compression;
using System.Numerics;
using System.Reflection;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace XFEEDToolBox.Core.Rendering;
public sealed record EliteMeshDraw(int MetadataOffset, int IndexCount, int VertexCount, int IndexOffset, int VertexOffset);
public sealed record EliteModelReference(string ShipType, string ResourceName, string ResourceHash, string SourceSha256,
int DeflateOffset, int InflatedBytes, int GpuOffset, int GpuBytes, int VertexBufferOffset, int VertexStride, EliteMeshDraw[] Meshes);
public sealed record EliteModelIndex(string GameSha256, int PublicKeyRva, int PublicKeyBytes, EliteModelReference[] Models);
/// <summary>Reads the installed game's display meshes without loading or changing its executable.</summary>
public sealed class EliteLocalModels
{
public static EliteModelIndex Reference { get; } = ReadReference();
private readonly string root;
private readonly BigInteger modulus, exponent;
private readonly int rsaBytes;
public static string Normalize(string type) => new(type.ToLowerInvariant().Where(char.IsAsciiLetterOrDigit).ToArray());
public EliteLocalModels(string gameExecutable)
{
root = Path.GetDirectoryName(Path.GetFullPath(gameExecutable))!;
using var file = File.OpenRead(gameExecutable);
if (!Convert.ToHexString(SHA256.HashData(file)).Equals(Reference.GameSha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("游戏版本已有变化,需要更新本地模型资源索引。");
file.Position = 0;
using var pe = new PEReader(file);
var key = pe.GetSectionData(Reference.PublicKeyRva).GetContent(0, Reference.PublicKeyBytes).ToArray();
using var rsa = RSA.Create(); rsa.ImportRSAPublicKey(key, out var consumed);
if (consumed != key.Length) throw new InvalidDataException("游戏模型资源公钥格式不符。");
var parameters = rsa.ExportParameters(false);
modulus = new(parameters.Modulus!, isUnsigned: true, isBigEndian: true);
exponent = new(parameters.Exponent!, isUnsigned: true, isBigEndian: true);
rsaBytes = parameters.Modulus!.Length;
}
public ShipMesh? Read(string shipType)
{
var model = Reference.Models.FirstOrDefault(m => Normalize(m.ShipType) == Normalize(shipType));
if (model is null) return null;
var bucket = Convert.ToInt32(model.ResourceHash[..1], 16).ToString("x2");
var path = Path.Combine(root, "Win64", bucket, model.ResourceHash);
var info = new FileInfo(path);
if (!info.Exists || info.Length is < 262 or > 128 * 1024 * 1024) throw new InvalidDataException("本地飞船模型资源缺失或超出大小限制。");
var archive = File.ReadAllBytes(path);
var hash = Convert.ToHexString(SHA256.HashData(archive));
if (!hash.Equals(model.SourceSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("本地模型资源已有变化,需要更新模型索引。");
var resource = DecodeArchive(archive, model.ResourceHash);
if (!resource.AsSpan(0, 4).SequenceEqual("FRES"u8) || model.DeflateOffset < 144 || model.DeflateOffset >= resource.Length)
throw new InvalidDataException("模型资源头无效。");
using var compressed = new ZLibStream(new MemoryStream(resource, model.DeflateOffset, resource.Length - model.DeflateOffset, false), CompressionMode.Decompress);
var data = new byte[model.InflatedBytes]; compressed.ReadExactly(data);
if (compressed.ReadByte() != -1) throw new InvalidDataException("模型数据长度与索引不符。");
if (model.GpuOffset < 0 || model.GpuBytes <= 0 || (long)model.GpuOffset + model.GpuBytes > data.Length) throw new InvalidDataException("模型网格数据越界。");
var triangles = new List<MeshVertex>(model.Meshes.Sum(m => m.IndexCount));
foreach (var draw in model.Meshes)
{
if (draw.IndexCount <= 0 || draw.IndexCount % 3 != 0 || draw.VertexCount is <= 0 or > 65536 ||
ReadInt(data, draw.MetadataOffset) != draw.IndexCount || ReadInt(data, draw.MetadataOffset + 4) != draw.VertexCount)
throw new InvalidDataException("船体绘制记录与资源索引不符。");
var vertices = new MeshVertex[draw.VertexCount];
var vertexStart = checked(model.GpuOffset + model.VertexBufferOffset + draw.VertexOffset);
if (vertexStart < model.GpuOffset || (long)vertexStart + vertices.Length * model.VertexStride > (long)model.GpuOffset + model.GpuBytes) throw new InvalidDataException("船体顶点数据越界。");
for (var i = 0; i < vertices.Length; i++)
{
var bytes = data.AsSpan(vertexStart + i * model.VertexStride, model.VertexStride);
var normal = new Vector3(bytes[8] / 127f - 1, bytes[9] / 127f - 1, bytes[10] / 127f - 1);
if (normal.LengthSquared() < .01f) normal = Vector3.UnitY; else normal = Vector3.Normalize(normal);
vertices[i] = new(DecodePosition(bytes), normal);
}
var indexStart = checked(model.GpuOffset + draw.IndexOffset);
if (indexStart < model.GpuOffset || (long)indexStart + draw.IndexCount * 2 > (long)model.GpuOffset + model.GpuBytes) throw new InvalidDataException("船体索引数据越界。");
for (var i = 0; i < draw.IndexCount; i++)
{
var index = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(indexStart + i * 2, 2));
if (index >= vertices.Length) throw new InvalidDataException("船体顶点索引越界。");
triangles.Add(vertices[index]);
}
}
return ShipMeshReader.Create(triangles.ToArray(), path + "#" + model.ResourceName, hash);
}
internal byte[] ReadDisplayResource(string resourceHash,string expectedHash)
{
if(resourceHash.Length!=40||!resourceHash.All(char.IsAsciiHexDigit)||expectedHash.Length!=64)throw new InvalidDataException("本地显示资源索引无效。");
var path=Path.Combine(root,"Win64",Convert.ToInt32(resourceHash[..1],16).ToString("x2"),resourceHash);
var file=new FileInfo(path);
if(!file.Exists||file.Length is <262 or >4*1024*1024)throw new InvalidDataException("本地显示资源缺失或过大。");
var data=File.ReadAllBytes(path);
if(!Convert.ToHexString(SHA256.HashData(data)).Equals(expectedHash,StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("本地显示资源版本不匹配。");
return DecodeArchive(data,resourceHash);
}
// Matches the installed game's PACKEDVERTEXDATAA vertex shader. The scale exponent is per vertex.
public static Vector3 DecodePosition(ReadOnlySpan<byte> vertex)
{
if (vertex.Length < 12) throw new InvalidDataException("船体压缩顶点不完整。");
var packed = BinaryPrimitives.ReadUInt64LittleEndian(vertex);
var power = vertex[11] & 127;
if (power == 64)
{
var radius = Math.Pow(2, (packed >> 48) * (16.0 / 65535)) - 1;
return new((float)((packed & 65535) * (2.0 / 65535) - 1) * (float)radius,
(float)(((packed >> 16) & 65535) * (2.0 / 65535) - 1) * (float)radius,
(float)(((packed >> 32) & 65535) * (2.0 / 65535) - 1) * (float)radius);
}
if (power > 19) throw new InvalidDataException("未知的船体坐标压缩参数。");
var scale = 1.0 / ((1 << (20 - power)) - 1); var bias = 1 << power;
return new((float)((packed & 0x1fffff) * scale - bias),
(float)(((packed >> 21) & 0x1fffff) * scale - bias), (float)(((packed >> 42) & 0x1fffff) * scale - bias));
}
private byte[] DecodeArchive(byte[] archive, string resourceHash)
{
if (!archive.AsSpan(0, 6).SequenceEqual(new byte[] { 70, 82, 69, 65, 0, 0 })) throw new InvalidDataException("模型资源封装版本不符。");
// FREA uses a public RSA signature block for its header; no account or license state is accessed.
var signature = new BigInteger(archive.AsSpan(6, rsaBytes), isUnsigned: true, isBigEndian: true);
var blockBytes = BigInteger.ModPow(signature, exponent, modulus).ToByteArray(isUnsigned: true, isBigEndian: true);
var block = new byte[rsaBytes]; blockBytes.CopyTo(block.AsSpan(rsaBytes - blockBytes.Length));
if (block[0] != 0 || block[1] != 1) throw new InvalidDataException("模型资源签名头无效。");
var separator = 2;
while (separator < block.Length && block[separator] == 255) separator++;
if (separator < 10 || separator >= block.Length || block[separator] != 0) throw new InvalidDataException("模型资源签名填充无效。");
var message = block.AsSpan(separator + 1);
if (message.Length < 40) throw new InvalidDataException("模型资源签名块不完整。");
var size = BinaryPrimitives.ReadInt32LittleEndian(message);
if (size is < 144 or > 128 * 1024 * 1024) throw new InvalidDataException("模型资源声明的长度无效。");
var mask = Encoding.ASCII.GetBytes(resourceHash.ToLowerInvariant());
var key = message.Slice(4, 32).ToArray();
for (var i = 0; i < key.Length; i++) key[i] ^= mask[i % mask.Length];
using var aes = Aes.Create(); aes.Key = key;
var tail = aes.DecryptEcb(archive.AsSpan(6 + rsaBytes), PaddingMode.None);
var prefix = message[36..];
if (size > prefix.Length + tail.Length) throw new InvalidDataException("模型资源长度不足。");
var result = new byte[size]; prefix[..Math.Min(size, prefix.Length)].CopyTo(result);
if (size > prefix.Length) tail.AsSpan(0, size - prefix.Length).CopyTo(result.AsSpan(prefix.Length));
return result;
}
private static int ReadInt(byte[] data, int offset) => offset >= 0 && offset <= data.Length - 4 ? BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4)) : -1;
private static EliteModelIndex ReadReference()
{
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("XFEEDToolBox.Core.Rendering.local-model-reference.json")!;
return JsonSerializer.Deserialize<EliteModelIndex>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
}