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); /// Reads the installed game's display meshes without loading or changing its executable. 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(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 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(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; } }