From eb458ad9a8cf06f158cd61f1775e403adb6493c8 Mon Sep 17 00:00:00 2001 From: bird_egop Date: Sat, 9 May 2026 20:36:43 +0300 Subject: [PATCH] fpopov thanks --- Common/IntFloatValue.cs | 13 +- MaterialLib/MaterialFile.cs | 220 +++++++++++----------- MaterialLib/MaterialParser.cs | 157 ++++++++------- MissionTmaLib/ClanInfo.cs | 54 +++--- MissionTmaLib/GameObjectInfo.cs | 64 +++---- MissionTmaLib/Parsing/MissionTmaParser.cs | 115 ++++++----- NResLib/NResArchive.cs | 55 +++--- NResLib/NResExporter.cs | 4 +- NResLib/NResParser.cs | 104 ++++++---- NResUI/App.cs | 10 +- NResUI/ImGuiUI/NResExplorerPanel.cs | 38 +--- NResUI/ImGuiUI/TexmExplorer.cs | 49 +++-- NResUI/Models/TexmExplorerViewModel.cs | 6 +- NResUI/OpenGlTexture.cs | 4 +- PalLib/PalFile.cs | 29 +-- PalLib/PalParser.cs | 7 +- ParkanPlayground.slnx | 11 +- ParkanPlayground/Effects/FxidReader.cs | 13 +- ParkanPlayground/Effects/FxidTypes.cs | 51 +++-- ParkanPlayground/MSH_FORMAT.md | 125 ++++++------ ParkanPlayground/Msh01.cs | 77 -------- ParkanPlayground/Msh02.cs | 193 ------------------- ParkanPlayground/Msh07.cs | 53 ------ ParkanPlayground/Msh0D.cs | 53 ------ ParkanPlayground/Msh0x01.cs | 89 +++++++++ ParkanPlayground/Msh0x02.cs | 203 ++++++++++++++++++++ ParkanPlayground/{Msh03.cs => Msh0x03.cs} | 14 +- ParkanPlayground/Msh0x04.cs | 54 ++++++ ParkanPlayground/Msh0x05.cs | 51 +++++ ParkanPlayground/{Msh06.cs => Msh0x06.cs} | 23 ++- ParkanPlayground/Msh0x07.cs | 80 ++++++++ ParkanPlayground/{Msh0A.cs => Msh0x0A.cs} | 31 ++- ParkanPlayground/Msh0x0D.cs | 70 +++++++ ParkanPlayground/Msh0x15.cs | 73 +++++++ ParkanPlayground/Msh15.cs | 55 ------ ParkanPlayground/MshConverter.cs | 161 +++++++++------- Program.cs | 7 +- README.md | 8 +- ScrLib/ScrFile.cs | 72 +++---- ScrLib/ScrParser.cs | 62 +++--- TexmLib/Extensions.cs | 11 +- TexmLib/TexmFile.cs | 165 ++++++++-------- TexmLib/TexmParser.cs | 61 +++--- 43 files changed, 1562 insertions(+), 1233 deletions(-) delete mode 100644 ParkanPlayground/Msh01.cs delete mode 100644 ParkanPlayground/Msh02.cs delete mode 100644 ParkanPlayground/Msh07.cs delete mode 100644 ParkanPlayground/Msh0D.cs create mode 100644 ParkanPlayground/Msh0x01.cs create mode 100644 ParkanPlayground/Msh0x02.cs rename ParkanPlayground/{Msh03.cs => Msh0x03.cs} (72%) create mode 100644 ParkanPlayground/Msh0x04.cs create mode 100644 ParkanPlayground/Msh0x05.cs rename ParkanPlayground/{Msh06.cs => Msh0x06.cs} (50%) create mode 100644 ParkanPlayground/Msh0x07.cs rename ParkanPlayground/{Msh0A.cs => Msh0x0A.cs} (58%) create mode 100644 ParkanPlayground/Msh0x0D.cs create mode 100644 ParkanPlayground/Msh0x15.cs delete mode 100644 ParkanPlayground/Msh15.cs diff --git a/Common/IntFloatValue.cs b/Common/IntFloatValue.cs index e22e146..31f3840 100644 --- a/Common/IntFloatValue.cs +++ b/Common/IntFloatValue.cs @@ -3,9 +3,14 @@ using System.Runtime.InteropServices; namespace Common; +/// DEBUG ONLY. Одни и те же 4 байта, интерпретированные как int32 и float32. +/// Значение как int32. +/// Значение как float32. [DebuggerDisplay("AsInt = {AsInt}, AsFloat = {AsFloat}")] -public class IntFloatValue(Span span) +public record class IntFloatValue(int AsInt, float AsFloat) { - public int AsInt { get; set; } = MemoryMarshal.Read(span); - public float AsFloat { get; set; } = MemoryMarshal.Read(span); -} \ No newline at end of file + public IntFloatValue(Span span) + : this(MemoryMarshal.Read(span), MemoryMarshal.Read(span)) + { + } +} diff --git a/MaterialLib/MaterialFile.cs b/MaterialLib/MaterialFile.cs index f8e491a..04f167a 100644 --- a/MaterialLib/MaterialFile.cs +++ b/MaterialLib/MaterialFile.cs @@ -1,152 +1,148 @@ namespace MaterialLib; -public class MaterialFile -{ - // === Metadata (not from file content) === - public string FileName { get; set; } - public int Version { get; set; } // From NRes ElementCount - public int Magic1 { get; set; } // From NRes Magic1 - - // === Derived from Version/ElementCount === - public int MaterialRenderingType { get; set; } // (Version >> 2) & 0xF - 0=Standard, 1=Special, 2=Particle - public bool SupportsBumpMapping { get; set; } // (Version & 2) != 0 - public int IsParticleEffect { get; set; } // Version & 40 - 0=Normal, 8=Particle/Effect - - // === File Content (in read order) === - // Read order: StageCount (ushort), AnimCount (ushort), then conditionally blend modes and params - - // Global Blend Modes (read if Magic1 >= 2) - public BlendMode SourceBlendMode { get; set; } // Default: Unknown (0xFF) - public BlendMode DestBlendMode { get; set; } // Default: Unknown (0xFF) - - // Global Parameters (read if Magic1 > 2 and > 3 respectively) - public float GlobalAlphaMultiplier { get; set; } // Default: 1.0 (always 1.0 in all 628 materials) - public float GlobalEmissiveIntensity { get; set; } // Default: 0.0 (0=no glow, rare values: 1000, 10000) - - public List Stages { get; set; } = new(); - public List Animations { get; set; } = new(); -} +/// Файл материала MAT0. +/// Имя файла из NRes metadata, не из payload материала. +/// Значение attr1 / ElementCount из NRes metadata. +/// Значение attr2 / Magic1 из NRes metadata. +/// Производное от Version: (Version >> 2) & 0xF. 0 = standard, 1 = special, 2 = particle. +/// Производное от Version: (Version & 2) != 0. +/// Производное от Version: Version & 40. 0 = normal, 8 = particle/effect. +/// Глобальный source blend mode; читается, если Magic1 >= 2. Значение по умолчанию: Unknown (0xFF). +/// Глобальный destination blend mode; читается, если Magic1 >= 2. Значение по умолчанию: Unknown (0xFF). +/// Глобальный alpha multiplier; читается, если Magic1 > 2. Значение по умолчанию: 1.0. +/// Глобальная emissive intensity; читается, если Magic1 > 3. Значение по умолчанию: 0.0. +/// Стадии материала в порядке чтения из файла. +/// Анимации материала в порядке чтения из файла. +public record MaterialFile( + string FileName, + int Version, + int Magic1, + int MaterialRenderingType, + bool SupportsBumpMapping, + int IsParticleEffect, + BlendMode SourceBlendMode, + BlendMode DestBlendMode, + float GlobalAlphaMultiplier, + float GlobalEmissiveIntensity, + List Stages, + List Animations); /// -/// Blend modes for material rendering. These control how source and destination colors are combined. -/// Formula: FinalColor = (SourceColor * SourceBlend) [operation] (DestColor * DestBlend) -/// Maps to Direct3D D3DBLEND values. +/// Режимы blend для материала. Управляют смешиванием source и destination цветов. +/// Формула: FinalColor = SourceColor * SourceBlend [operation] DestColor * DestBlend. +/// Соответствуют значениям Direct3D D3DBLEND. /// public enum BlendMode : byte { - /// Blend factor is (0, 0, 0, 0) - results in black/transparent + /// Blend factor = (0, 0, 0, 0). Zero = 1, - /// Blend factor is (1, 1, 1, 1) - uses full color value + /// Blend factor = (1, 1, 1, 1). One = 2, - /// Blend factor is (Rs, Gs, Bs, As) - uses source color + /// Blend factor = (Rs, Gs, Bs, As). SrcColor = 3, - /// Blend factor is (1-Rs, 1-Gs, 1-Bs, 1-As) - uses inverted source color + /// Blend factor = (1-Rs, 1-Gs, 1-Bs, 1-As). InvSrcColor = 4, - /// Blend factor is (As, As, As, As) - uses source alpha for all channels (standard transparency) + /// Blend factor = (As, As, As, As). SrcAlpha = 5, - /// Blend factor is (1-As, 1-As, 1-As, 1-As) - uses inverted source alpha + /// Blend factor = (1-As, 1-As, 1-As, 1-As). InvSrcAlpha = 6, - /// Blend factor is (Ad, Ad, Ad, Ad) - uses destination alpha + /// Blend factor = (Ad, Ad, Ad, Ad). DestAlpha = 7, - /// Blend factor is (1-Ad, 1-Ad, 1-Ad, 1-Ad) - uses inverted destination alpha + /// Blend factor = (1-Ad, 1-Ad, 1-Ad, 1-Ad). InvDestAlpha = 8, - /// Blend factor is (Rd, Gd, Bd, Ad) - uses destination color + /// Blend factor = (Rd, Gd, Bd, Ad). DestColor = 9, - /// Blend factor is (1-Rd, 1-Gd, 1-Bd, 1-Ad) - uses inverted destination color + /// Blend factor = (1-Rd, 1-Gd, 1-Bd, 1-Ad). InvDestColor = 10, - /// Blend factor is (f, f, f, 1) where f = min(As, 1-Ad) - saturates source alpha + /// Blend factor = (f, f, f, 1), где f = min(As, 1-Ad). SrcAlphaSat = 11, - /// Blend factor is (As, As, As, As) for both source and dest - obsolete in D3D9+ + /// Blend factor = (As, As, As, As) для source и destination; устарело в D3D9+. BothSrcAlpha = 12, - /// Blend factor is (1-As, 1-As, 1-As, 1-As) for both source and dest - obsolete in D3D9+ + /// Blend factor = (1-As, 1-As, 1-As, 1-As) для source и destination; устарело в D3D9+. BothInvSrcAlpha = 13, - /// Unknown or uninitialized blend mode (0xFF default value) + /// Неизвестный или неинициализированный режим blend; значение по умолчанию 0xFF. Unknown = 0xFF } -public class MaterialStage -{ - // === FILE READ ORDER (34 bytes per stage) === - // This matches the order bytes are read from the file (decompiled.c lines 159-217) - // NOT the C struct memory layout (which is Diffuse, Ambient, Specular, Emissive) - - // 1. Ambient Color (4 bytes, read first from file) - public float AmbientR; - public float AmbientG; - public float AmbientB; - public float AmbientA; // Scaled by 0.01 when read from file - - // 2. Diffuse Color (4 bytes, read second from file) - public float DiffuseR; - public float DiffuseG; - public float DiffuseB; - public float DiffuseA; +/// Стадия материала. Порядок полей соответствует порядку чтения из файла, а не C struct layout. +/// Ambient R, читается первым цветовым блоком. +/// Ambient G, читается первым цветовым блоком. +/// Ambient B, читается первым цветовым блоком. +/// Ambient A, читается первым цветовым блоком и масштабируется на 0.01. +/// Diffuse R, читается вторым цветовым блоком. +/// Diffuse G, читается вторым цветовым блоком. +/// Diffuse B, читается вторым цветовым блоком. +/// Diffuse A, читается вторым цветовым блоком. +/// Specular R, читается третьим цветовым блоком. +/// Specular G, читается третьим цветовым блоком. +/// Specular B, читается третьим цветовым блоком. +/// Specular A, читается третьим цветовым блоком. +/// Emissive R, читается четвертым цветовым блоком. +/// Emissive G, читается четвертым цветовым блоком. +/// Emissive B, читается четвертым цветовым блоком. +/// Emissive A, читается четвертым цветовым блоком. +/// Power, один байт -> float. +/// Индекс texture stage. 255 = не задано/default, 0..47 = ссылка на texture stage. +/// Имя текстуры, строка из 16 байт. +public record MaterialStage( + float AmbientR, + float AmbientG, + float AmbientB, + float AmbientA, + float DiffuseR, + float DiffuseG, + float DiffuseB, + float DiffuseA, + float SpecularR, + float SpecularG, + float SpecularB, + float SpecularA, + float EmissiveR, + float EmissiveG, + float EmissiveB, + float EmissiveA, + float Power, + int TextureStageIndex, + string TextureName); - // 3. Specular Color (4 bytes, read third from file) - public float SpecularR; - public float SpecularG; - public float SpecularB; - public float SpecularA; - - // 4. Emissive Color (4 bytes, read fourth from file) - public float EmissiveR; - public float EmissiveG; - public float EmissiveB; - public float EmissiveA; - - // 5. Power (1 byte → float, read fifth from file) - public float Power; - - // 6. Texture Stage Index (1 byte, read sixth from file) - // 255 = not set/default, 0-47 = reference to specific texture stage - public int TextureStageIndex; - - // 7. Texture Name (16 bytes, read seventh from file) - public string TextureName { get; set; } -} - -public class MaterialAnimation -{ - // === File Read Order === - - // Combined field (4 bytes): bits 3-31 = Target, bits 0-2 = LoopMode - public AnimationTarget Target; - public AnimationLoopMode LoopMode; - - // Key count (2 bytes), then keys - public List Keys { get; set; } = new(); - - // Cached description for UI (computed once during parsing) - public string TargetDescription { get; set; } = string.Empty; -} +/// Анимация материала. +/// Целевые компоненты анимации: биты 3..31 combined field. +/// Режим повтора: биты 0..2 combined field. +/// Ключи анимации. +/// Кэшированное описание target для UI. +public record MaterialAnimation( + AnimationTarget Target, + AnimationLoopMode LoopMode, + List Keys, + string TargetDescription); [Flags] public enum AnimationTarget : int { - // NOTE: This is a BITSET (flags enum). Multiple flags can be combined. - // When a flag is SET, that component is INTERPOLATED between stages. - // When a flag is NOT SET, that component is COPIED from the source stage (no interpolation). - // If ALL flags are 0, the ENTIRE stage is copied without any interpolation. + // Это bitset: несколько флагов могут комбинироваться. + // Установленный флаг означает интерполяцию компонента между стадиями. + // Неустановленный флаг означает копирование компонента из исходной стадии. + // Если все флаги равны 0, вся стадия копируется без интерполяции. - Ambient = 1, // 0x01 - Interpolates Ambient RGB (Interpolate.c lines 23-37) - Diffuse = 2, // 0x02 - Interpolates Diffuse RGB (Interpolate.c lines 7-21) - Specular = 4, // 0x04 - Interpolates Specular RGB (Interpolate.c lines 39-53) - Emissive = 8, // 0x08 - Interpolates Emissive RGB (Interpolate.c lines 55-69) - Power = 16 // 0x10 - Interpolates Ambient.A and sets Power (Interpolate.c lines 71-76) + Ambient = 1, // 0x01: интерполирует Ambient RGB. + Diffuse = 2, // 0x02: интерполирует Diffuse RGB. + Specular = 4, // 0x04: интерполирует Specular RGB. + Emissive = 8, // 0x08: интерполирует Emissive RGB. + Power = 16 // 0x10: интерполирует Ambient.A и задает Power. } @@ -158,10 +154,8 @@ public enum AnimationLoopMode : int Random = 3 } -public struct AnimKey -{ - // === File Read Order (6 bytes per key) === - public ushort StageIndex; // Read first - public ushort DurationMs; // Read second - public ushort InterpolationCurve; // Read third - Always 0 (linear interpolation) in all 1848 keys -} +/// Ключ анимации материала (length = 6). +/// [0x00..0x02] Индекс стадии материала. +/// [0x02..0x04] Длительность в миллисекундах. +/// [0x04..0x06] Кривая интерполяции. В исследованных данных всегда 0. +public readonly record struct AnimKey(ushort StageIndex, ushort DurationMs, ushort InterpolationCurve); diff --git a/MaterialLib/MaterialParser.cs b/MaterialLib/MaterialParser.cs index b8804d4..bd1a126 100644 --- a/MaterialLib/MaterialParser.cs +++ b/MaterialLib/MaterialParser.cs @@ -7,17 +7,9 @@ public static class MaterialParser { public static MaterialFile ReadFromStream(Stream fs, string fileName, int elementCount, int magic1) { - var file = new MaterialFile - { - FileName = fileName, - Version = elementCount, - Magic1 = magic1 - }; - - // Derived fields - file.MaterialRenderingType = elementCount >> 2 & 0xf; - file.SupportsBumpMapping = (elementCount & 2) != 0; - file.IsParticleEffect = elementCount & 40; + var materialRenderingType = elementCount >> 2 & 0xf; + var supportsBumpMapping = (elementCount & 2) != 0; + var isParticleEffect = elementCount & 40; // Reading content var stageCount = fs.ReadUInt16LittleEndian(); @@ -25,113 +17,140 @@ public static class MaterialParser uint magic = (uint)magic1; - // Defaults found in C code - file.GlobalAlphaMultiplier = 1.0f; // field8_0x15c - file.GlobalEmissiveIntensity = 0.0f; // field9_0x160 - file.SourceBlendMode = BlendMode.Unknown; // field6_0x154 - file.DestBlendMode = BlendMode.Unknown; // field7_0x158 + // Значения по умолчанию из C-кода. + var globalAlphaMultiplier = 1.0f; // field8_0x15c + var globalEmissiveIntensity = 0.0f; // field9_0x160 + var sourceBlendMode = BlendMode.Unknown; // field6_0x154 + var destBlendMode = BlendMode.Unknown; // field7_0x158 if (magic >= 2) { - file.SourceBlendMode = (BlendMode)fs.ReadByte(); - file.DestBlendMode = (BlendMode)fs.ReadByte(); + sourceBlendMode = (BlendMode)fs.ReadByte(); + destBlendMode = (BlendMode)fs.ReadByte(); } if (magic > 2) { - file.GlobalAlphaMultiplier = fs.ReadFloatLittleEndian(); + globalAlphaMultiplier = fs.ReadFloatLittleEndian(); } if (magic > 3) { - file.GlobalEmissiveIntensity = fs.ReadFloatLittleEndian(); + globalEmissiveIntensity = fs.ReadFloatLittleEndian(); } - // --- 2. Material Stages --- + // Стадии материала. const float Inv255 = 1.0f / 255.0f; const float Field7Mult = 0.01f; Span textureNameBuffer = stackalloc byte[16]; + List stages = []; for (int i = 0; i < stageCount; i++) { - var stage = new MaterialStage(); - - // === FILE READ ORDER (matches decompiled.c lines 159-217) === + // Порядок чтения соответствует файлу, а не C struct layout. - // 1. Ambient (4 bytes, A scaled by 0.01) - Lines 159-168 - stage.AmbientR = fs.ReadByte() * Inv255; - stage.AmbientG = fs.ReadByte() * Inv255; - stage.AmbientB = fs.ReadByte() * Inv255; - stage.AmbientA = fs.ReadByte() * Field7Mult; // 0.01 scaling + // Ambient: 4 байта, A масштабируется на 0.01. + var ambientR = fs.ReadByte() * Inv255; + var ambientG = fs.ReadByte() * Inv255; + var ambientB = fs.ReadByte() * Inv255; + var ambientA = fs.ReadByte() * Field7Mult; // 0.01 scaling - // 2. Diffuse (4 bytes) - Lines 171-180 - stage.DiffuseR = fs.ReadByte() * Inv255; - stage.DiffuseG = fs.ReadByte() * Inv255; - stage.DiffuseB = fs.ReadByte() * Inv255; - stage.DiffuseA = fs.ReadByte() * Inv255; + // Diffuse: 4 байта. + var diffuseR = fs.ReadByte() * Inv255; + var diffuseG = fs.ReadByte() * Inv255; + var diffuseB = fs.ReadByte() * Inv255; + var diffuseA = fs.ReadByte() * Inv255; - // 3. Specular (4 bytes) - Lines 183-192 - stage.SpecularR = fs.ReadByte() * Inv255; - stage.SpecularG = fs.ReadByte() * Inv255; - stage.SpecularB = fs.ReadByte() * Inv255; - stage.SpecularA = fs.ReadByte() * Inv255; + // Specular: 4 байта. + var specularR = fs.ReadByte() * Inv255; + var specularG = fs.ReadByte() * Inv255; + var specularB = fs.ReadByte() * Inv255; + var specularA = fs.ReadByte() * Inv255; - // 4. Emissive (4 bytes) - Lines 195-204 - stage.EmissiveR = fs.ReadByte() * Inv255; - stage.EmissiveG = fs.ReadByte() * Inv255; - stage.EmissiveB = fs.ReadByte() * Inv255; - stage.EmissiveA = fs.ReadByte() * Inv255; + // Emissive: 4 байта. + var emissiveR = fs.ReadByte() * Inv255; + var emissiveG = fs.ReadByte() * Inv255; + var emissiveB = fs.ReadByte() * Inv255; + var emissiveA = fs.ReadByte() * Inv255; - // 5. Power (1 byte → float) - Line 207 - stage.Power = (float)fs.ReadByte(); + // Power: 1 байт -> float. + var power = (float)fs.ReadByte(); - // 6. Texture Stage Index (1 byte) - Line 210 - stage.TextureStageIndex = fs.ReadByte(); + // Texture stage index: 1 байт. + var textureStageIndex = fs.ReadByte(); - // 7. Texture Name (16 bytes) - Lines 212-217 + // Texture name: 16 байт. textureNameBuffer.Clear(); fs.ReadExactly(textureNameBuffer); - stage.TextureName = Encoding.ASCII.GetString(textureNameBuffer).TrimEnd('\0'); + var textureName = Encoding.ASCII.GetString(textureNameBuffer).TrimEnd('\0'); - file.Stages.Add(stage); + stages.Add(new MaterialStage( + ambientR, + ambientG, + ambientB, + ambientA, + diffuseR, + diffuseG, + diffuseB, + diffuseA, + specularR, + specularG, + specularB, + specularA, + emissiveR, + emissiveG, + emissiveB, + emissiveA, + power, + textureStageIndex, + textureName)); } - // --- 3. Animations --- + // Анимации. + List animations = []; for (int i = 0; i < animCount; i++) { - var anim = new MaterialAnimation(); - uint typeAndParams = fs.ReadUInt32LittleEndian(); - anim.Target = (AnimationTarget)(typeAndParams >> 3); - anim.LoopMode = (AnimationLoopMode)(typeAndParams & 7); + var target = (AnimationTarget)(typeAndParams >> 3); + var loopMode = (AnimationLoopMode)(typeAndParams & 7); ushort keyCount = fs.ReadUInt16LittleEndian(); + List keys = []; for (int k = 0; k < keyCount; k++) { - var key = new AnimKey - { - StageIndex = fs.ReadUInt16LittleEndian(), - DurationMs = fs.ReadUInt16LittleEndian(), - InterpolationCurve = fs.ReadUInt16LittleEndian() - }; - anim.Keys.Add(key); + keys.Add(new AnimKey( + fs.ReadUInt16LittleEndian(), + fs.ReadUInt16LittleEndian(), + fs.ReadUInt16LittleEndian())); } - // Precompute description for UI to avoid per-frame allocations - anim.TargetDescription = ComputeTargetDescription(anim.Target); + // Описание вычисляется один раз при парсинге, чтобы UI не выделял память каждый кадр. + var targetDescription = ComputeTargetDescription(target); - file.Animations.Add(anim); + animations.Add(new MaterialAnimation(target, loopMode, keys, targetDescription)); } - return file; + return new MaterialFile( + fileName, + elementCount, + magic1, + materialRenderingType, + supportsBumpMapping, + isParticleEffect, + sourceBlendMode, + destBlendMode, + globalAlphaMultiplier, + globalEmissiveIntensity, + stages, + animations); } private static string ComputeTargetDescription(AnimationTarget target) { // Precompute the description once during parsing if ((int)target == 0) - return "No interpolation - entire stage is copied as-is (Flags: 0x0)"; + return "Без интерполяции - вся стадия копируется как есть (Flags: 0x0)"; var parts = new List(); @@ -146,7 +165,7 @@ public static class MaterialParser if ((target & AnimationTarget.Power) != 0) parts.Add("Ambient.A + Power"); - return $"Interpolates: {string.Join(", ", parts)} | Other components copied (Flags: 0x{(int)target:X})"; + return $"Интерполируется: {string.Join(", ", parts)} | Остальные компоненты копируются (Flags: 0x{(int)target:X})"; } } diff --git a/MissionTmaLib/ClanInfo.cs b/MissionTmaLib/ClanInfo.cs index 9067a92..e7f12fc 100644 --- a/MissionTmaLib/ClanInfo.cs +++ b/MissionTmaLib/ClanInfo.cs @@ -1,30 +1,28 @@ namespace MissionTmaLib; -public class ClanInfo -{ - public string ClanName { get; set; } - public int UnkInt1 { get; set; } - public float X { get; set; } - public float Y { get; set; } - - /// - /// 1 - игрок, 2 AI, 3 - нейтральный - /// - public ClanType ClanType { get; set; } - - public string ScriptsString { get; set; } - public int UnknownClanPartCount { get; set; } - public List UnknownParts { get; set; } - - /// - /// Игра называет этот путь TreeName - /// - public string ResearchNResPath { get; set; } - public int Brains { get; set; } - public int AlliesMapCount { get; set; } - - /// - /// мапа союзников (ключ - имя клана, значение - число, всегда либо 0 либо 1) - /// - public Dictionary AlliesMap { get; set; } -} \ No newline at end of file +/// Информация о клане из mission TMA. +/// Имя клана. +/// Неизвестное целочисленное поле. +/// TODO +/// TODO +/// Тип клана: 1 = игрок, 2 = AI, 3 = нейтральный. +/// TODO +/// Количество записей UnknownParts. +/// TODO +/// Путь к NRes с деревом исследований. Игра называет этот путь TreeName. +/// Количество "мозгов" AI/brains. +/// Количество записей AlliesMap. +/// Мапа союзников: ключ = имя клана, значение всегда 0 или 1. +public record ClanInfo( + string ClanName, + int UnkInt1, + float X, + float Y, + ClanType ClanType, + string ScriptsString, + int UnknownClanPartCount, + List UnknownParts, + string ResearchNResPath, + int Brains, + int AlliesMapCount, + Dictionary AlliesMap); diff --git a/MissionTmaLib/GameObjectInfo.cs b/MissionTmaLib/GameObjectInfo.cs index c124f5f..f155458 100644 --- a/MissionTmaLib/GameObjectInfo.cs +++ b/MissionTmaLib/GameObjectInfo.cs @@ -2,39 +2,31 @@ namespace MissionTmaLib; -public class GameObjectInfo -{ - // 0 - здание, 1 - бот, 2 - окружение - public GameObjectType Type { get; set; } - - public int UnknownFlags { get; set; } - - public string DatString { get; set; } - - /// - /// Индекс клана, которому принадлежит объект - /// - /// - /// - /// Некоторые объекты окружения иногда почему-то принадлежат клану отличному от -1 - /// - /// - /// Может быть -1, если объект никому не принадлежит, я такое встречал только у объектов окружения - /// - /// - public int OwningClanIndex { get; set; } - - public int Order { get; set; } - - public Vector3 Position { get; set; } - public Vector3 Rotation { get; set; } - public Vector3 Scale { get; set; } - - public string UnknownString2 { get; set; } - - public int UnknownInt4 { get; set; } - public int UnknownInt5 { get; set; } - public int UnknownInt6 { get; set; } - - public GameObjectSettings Settings { get; set; } -} \ No newline at end of file +/// Информация об объекте миссии из mission TMA. +/// Тип объекта: 0 = здание, 1 = бот, 2 = окружение. +/// Неизвестные флаги объекта. +/// Путь к DAT ресурсу объекта. +/// Индекс клана-владельца. Может быть -1, если объект никому не принадлежит. Некоторые объекты окружения иногда почему-то принадлежат клану отличному от -1 +/// Порядок объекта. Для зданий парсер добавляет int.MaxValue. +/// Позиция объекта. +/// Поворот объекта. +/// Масштаб объекта. Для старых feature set может быть принудительно (1, 1, 1). +/// Неизвестная строка, например у HERO бывает пустой. +/// Неизвестное целочисленное поле. +/// Неизвестное целочисленное поле. +/// Неизвестное целочисленное поле. +/// Настройки объекта. +public record class GameObjectInfo( + GameObjectType Type, + int UnknownFlags, + string DatString, + int OwningClanIndex, + int Order, + Vector3 Position, + Vector3 Rotation, + Vector3 Scale, + string UnknownString2, + int UnknownInt4, + int UnknownInt5, + int UnknownInt6, + GameObjectSettings Settings); diff --git a/MissionTmaLib/Parsing/MissionTmaParser.cs b/MissionTmaLib/Parsing/MissionTmaParser.cs index c05ee22..3df2dbf 100644 --- a/MissionTmaLib/Parsing/MissionTmaParser.cs +++ b/MissionTmaLib/Parsing/MissionTmaParser.cs @@ -72,32 +72,36 @@ public class MissionTmaParser List infos = []; for (var i = 0; i < clanCount; i++) { - var clanTreeInfo = new ClanInfo(); - - clanTreeInfo.ClanName = fileStream.ReadLengthPrefixedString(); - clanTreeInfo.UnkInt1 = fileStream.ReadInt32LittleEndian(); - clanTreeInfo.X = fileStream.ReadFloatLittleEndian(); - clanTreeInfo.Y = fileStream.ReadFloatLittleEndian(); - clanTreeInfo.ClanType = (ClanType) fileStream.ReadInt32LittleEndian(); + var clanName = fileStream.ReadLengthPrefixedString(); + var unkInt1 = fileStream.ReadInt32LittleEndian(); + var x = fileStream.ReadFloatLittleEndian(); + var y = fileStream.ReadFloatLittleEndian(); + var clanType = (ClanType) fileStream.ReadInt32LittleEndian(); + var scriptsString = string.Empty; + var unknownClanPartCount = 0; + List unknownParts = []; + var researchNResPath = string.Empty; + var brains = 0; + var alliesMapCount = 0; + Dictionary alliesMap = []; if (1 < clanFeatureSet) { // MISSIONS\SCRIPTS\default // MISSIONS\SCRIPTS\tut1_pl // MISSIONS\SCRIPTS\tut1_en - clanTreeInfo.ScriptsString = fileStream.ReadLengthPrefixedString(); + scriptsString = fileStream.ReadLengthPrefixedString(); } if (2 < clanFeatureSet) { - clanTreeInfo.UnknownClanPartCount = fileStream.ReadInt32LittleEndian(); + unknownClanPartCount = fileStream.ReadInt32LittleEndian(); // тут игра читает число, затем 12 байт и ещё 2 числа - List unknownClanTreeInfoParts = []; - for (var i1 = 0; i1 < clanTreeInfo.UnknownClanPartCount; i1++) + for (var i1 = 0; i1 < unknownClanPartCount; i1++) { - unknownClanTreeInfoParts.Add( + unknownParts.Add( new UnknownClanTreeInfoPart( fileStream.ReadInt32LittleEndian(), new Vector3( @@ -110,8 +114,6 @@ public class MissionTmaParser ) ); } - - clanTreeInfo.UnknownParts = unknownClanTreeInfoParts; } if (3 < clanFeatureSet) @@ -120,17 +122,17 @@ public class MissionTmaParser // MISSIONS\SCRIPTS\data.trf // указатель на NRes файл с данными // может быть пустым, например у Ntrl в туториале - clanTreeInfo.ResearchNResPath = fileStream.ReadLengthPrefixedString(); + researchNResPath = fileStream.ReadLengthPrefixedString(); } if (4 < clanFeatureSet) { - clanTreeInfo.Brains = fileStream.ReadInt32LittleEndian(); + brains = fileStream.ReadInt32LittleEndian(); } if (5 < clanFeatureSet) { - clanTreeInfo.AlliesMapCount = fileStream.ReadInt32LittleEndian(); + alliesMapCount = fileStream.ReadInt32LittleEndian(); // тут какая-то мапа // в демо миссии тут @@ -142,20 +144,29 @@ public class MissionTmaParser // Trgt -> 1 // Enm -> 0 // Ntrl -> 1 - Dictionary map = []; - for (var i1 = 0; i1 < clanTreeInfo.AlliesMapCount; i1++) + for (var i1 = 0; i1 < alliesMapCount; i1++) { var keyIdString = fileStream.ReadLengthPrefixedString(); // это число всегда либо 0 либо 1 var unkNumber = fileStream.ReadInt32LittleEndian(); - map[keyIdString] = unkNumber; + alliesMap[keyIdString] = unkNumber; } - - clanTreeInfo.AlliesMap = map; } - infos.Add(clanTreeInfo); + infos.Add(new ClanInfo( + clanName, + unkInt1, + x, + y, + clanType, + scriptsString, + unknownClanPartCount, + unknownParts, + researchNResPath, + brains, + alliesMapCount, + alliesMap)); } var clanInfo = new ClansFileData(clanFeatureSet, clanCount, infos); @@ -177,51 +188,58 @@ public class MissionTmaParser for (var i = 0; i < gameObjectsCount; i++) { - var gameObjectInfo = new GameObjectInfo(); // ReadGameObjectData - gameObjectInfo.Type = (GameObjectType) fileStream.ReadInt32LittleEndian(); - gameObjectInfo.UnknownFlags = fileStream.ReadInt32LittleEndian(); + var type = (GameObjectType) fileStream.ReadInt32LittleEndian(); + var unknownFlags = fileStream.ReadInt32LittleEndian(); // UNITS\UNITS\HERO\hero_t.dat - gameObjectInfo.DatString = fileStream.ReadLengthPrefixedString(); + var datString = fileStream.ReadLengthPrefixedString(); + var owningClanIndex = 0; + var order = 0; + var unknownString2 = string.Empty; + var unknownInt4 = 0; + var unknownInt5 = 0; + var unknownInt6 = 0; + var settingsData = new GameObjectSettings(0, 0, []); if (2 < gameObjectsFeatureSet) { - gameObjectInfo.OwningClanIndex = fileStream.ReadInt32LittleEndian(); + owningClanIndex = fileStream.ReadInt32LittleEndian(); } if (3 < gameObjectsFeatureSet) { - gameObjectInfo.Order = fileStream.ReadInt32LittleEndian(); - if (gameObjectInfo.Type == GameObjectType.Building) + order = fileStream.ReadInt32LittleEndian(); + if (type == GameObjectType.Building) { - gameObjectInfo.Order += int.MaxValue; + order += int.MaxValue; } } // читает 12 байт - gameObjectInfo.Position = new Vector3( + var position = new Vector3( fileStream.ReadFloatLittleEndian(), fileStream.ReadFloatLittleEndian(), fileStream.ReadFloatLittleEndian() ); // ещё раз читает 12 байт - gameObjectInfo.Rotation = new Vector3( + var rotation = new Vector3( fileStream.ReadFloatLittleEndian(), fileStream.ReadFloatLittleEndian(), fileStream.ReadFloatLittleEndian() ); + Vector3 scale; if (gameObjectsFeatureSet < 10) { // если фичесет меньше 10, то игра забивает вектор единицами - gameObjectInfo.Scale = new Vector3(1, 1, 1); + scale = new Vector3(1, 1, 1); } else { // в противном случае читает ещё вектор из файла - gameObjectInfo.Scale = new Vector3( + scale = new Vector3( fileStream.ReadFloatLittleEndian(), fileStream.ReadFloatLittleEndian(), fileStream.ReadFloatLittleEndian() @@ -231,18 +249,18 @@ public class MissionTmaParser if (6 < gameObjectsFeatureSet) { // у HERO пустая строка - gameObjectInfo.UnknownString2 = fileStream.ReadLengthPrefixedString(); + unknownString2 = fileStream.ReadLengthPrefixedString(); } if (7 < gameObjectsFeatureSet) { - gameObjectInfo.UnknownInt4 = fileStream.ReadInt32LittleEndian(); + unknownInt4 = fileStream.ReadInt32LittleEndian(); } if (8 < gameObjectsFeatureSet) { - gameObjectInfo.UnknownInt5 = fileStream.ReadInt32LittleEndian(); - gameObjectInfo.UnknownInt6 = fileStream.ReadInt32LittleEndian(); + unknownInt5 = fileStream.ReadInt32LittleEndian(); + unknownInt6 = fileStream.ReadInt32LittleEndian(); } if (5 < gameObjectsFeatureSet) @@ -318,10 +336,23 @@ public class MissionTmaParser ); } - gameObjectInfo.Settings = new GameObjectSettings(unused, innerCount, settings); + settingsData = new GameObjectSettings(unused, innerCount, settings); } - gameObjectInfos.Add(gameObjectInfo); + gameObjectInfos.Add(new GameObjectInfo( + type, + unknownFlags, + datString, + owningClanIndex, + order, + position, + rotation, + scale, + unknownString2, + unknownInt4, + unknownInt5, + unknownInt6, + settingsData)); // end ReadGameObjectData } @@ -389,4 +420,4 @@ public class MissionTmaParser lodeData ); } -} \ No newline at end of file +} diff --git a/NResLib/NResArchive.cs b/NResLib/NResArchive.cs index 7ddd70a..8a4f3d3 100644 --- a/NResLib/NResArchive.cs +++ b/NResLib/NResArchive.cs @@ -1,4 +1,4 @@ -namespace NResLib; +namespace NResLib; /// /// Архив NRes (файл NRes) @@ -8,40 +8,41 @@ public record NResArchive(NResArchiveHeader Header, List Files /// /// Заголовок файла /// -/// [0..4] ASCII NRes -/// [4..8] Версия кодировщика (должно быть всегда 0x100) -/// [8..12] Количество файлов -/// [12..16] Длина всего архива +/// [0..4] ASCII NRes. +/// [4..8] Версия кодировщика, обычно 0x100. +/// [8..12] Количество записей каталога. +/// [12..16] Длина всего архива. public record NResArchiveHeader(string NRes, int Version, int FileCount, int TotalFileLengthBytes); /// -/// В конце файла есть список метаданных, -/// каждый элемент это 64 байта, -/// найти начало можно как (Header.TotalFileLengthBytes - Header.FileCount * 64) +/// 64-байтная запись каталога NRes. +/// +/// Важный нюанс RE-имен: +/// - ElementCount/Magic1/ElementSize оставлены как удобные имена для исследования, но на уровне +/// общего NRes-контейнера это attr1/attr2/attr3. Их смысл зависит от TypeId ресурса. +/// - FileName на диске занимает 36 байт: [20..56]. Старый разбор читал только [20..40]. +/// - Старые Magic3..Magic6 оказались не отдельными полями формата, а байтами хвоста name_raw. +/// - SortIndex на диске это перестановка для бинарного поиска по имени, а не порядковый +/// индекс записи каталога. Порядковый индекс хранится отдельно в DirectoryIndex. /// -/// [0..4] ASCII описание типа файла, например TEXM или MAT0 -/// [4..8] Количество элементов в файле (если файл составной, например .trf) или версия, например у материалов или флаги -/// [8..12] Неизвестное число -/// [12..16] Длина файла в байтах -/// [16..20] Размер элемента в файле (если файл составной, например .trf) -/// [20..40] ASCII имя файла -/// [40..44] Неизвестное число -/// [44..48] Неизвестное число -/// [48..52] Неизвестное число -/// [52..56] Неизвестное число -/// [56..60] Смещение подфайла от начала NRes (именно самого NRes) в байтах -/// [60..64] Индекс в файле (от 0, не больше чем кол-во файлов) -public record ListMetadataItem( +/// [0x00..0x04] Resource type id. +/// [0x00..0x04] Resource type id как ASCII или hex bytes для RE. +/// [0x04..0x08] ElementCount. смысл зависит от TypeId. +/// [0x08..0x0C] смысл зависит от TypeId. +/// [0x0C..0x10] Длина payload в байтах. +/// [0x10..0x14] ElementSize. смысл зависит от TypeId. +/// [0x14..0x38] C-string из 36-байтного name_raw. +/// [0x38..0x3C] Смещение payload от начала архива. +/// [0x3C..0x40] Индекс перестановки для бинарного поиска по имени. +/// Индекс записи в порядке каталога; не хранится отдельным полем NRes. +public sealed record class ListMetadataItem( + uint TypeId, string FileType, uint ElementCount, int Magic1, int FileLength, int ElementSize, string FileName, - int Magic3, - int Magic4, - int Magic5, - int Magic6, int OffsetInFile, - int Index -); \ No newline at end of file + int SortIndex, + int DirectoryIndex); diff --git a/NResLib/NResExporter.cs b/NResLib/NResExporter.cs index 7396049..052a675 100644 --- a/NResLib/NResExporter.cs +++ b/NResLib/NResExporter.cs @@ -30,9 +30,9 @@ public class NResExporter extension = ".bin"; } - var targetFilePath = Path.Combine(targetDirectoryPath, $"{archiveFile.Index}_{archiveFile.FileType}_{fileName}{extension}"); + var targetFilePath = Path.Combine(targetDirectoryPath, $"{archiveFile.DirectoryIndex}_{archiveFile.FileType}_{fileName}{extension}"); File.WriteAllBytes(targetFilePath, buffer); } } -} \ No newline at end of file +} diff --git a/NResLib/NResParser.cs b/NResLib/NResParser.cs index e15baac..11e0bdd 100644 --- a/NResLib/NResParser.cs +++ b/NResLib/NResParser.cs @@ -35,6 +35,16 @@ public static class NResParser TotalFileLengthBytes: BinaryPrimitives.ReadInt32LittleEndian(buffer[12..16]) ); + if (header.Version != 0x100) + { + return new NResParseResult(null, $"Неожиданная версия NRes: 0x{header.Version:X}"); + } + + if (header.FileCount < 0) + { + return new NResParseResult(null, $"Некорректное количество записей NRes: {header.FileCount}"); + } + if (header.TotalFileLengthBytes != nResFs.Length) { return new NResParseResult( @@ -45,7 +55,14 @@ public static class NResParser ); } - nResFs.Seek(-header.FileCount * 64, SeekOrigin.End); + var directorySize = header.FileCount * 64L; + var directoryOffset = header.TotalFileLengthBytes - directorySize; + if (directoryOffset < 16 || directoryOffset + directorySize != header.TotalFileLengthBytes) + { + return new NResParseResult(null, "Некорректное расположение каталога NRes"); + } + + nResFs.Seek(directoryOffset, SeekOrigin.Begin); var elements = new List(header.FileCount); @@ -53,41 +70,32 @@ public static class NResParser for (int i = 0; i < header.FileCount; i++) { nResFs.ReadExactly(metaDataBuffer); - var type = ""; + var typeId = BinaryPrimitives.ReadUInt32LittleEndian(metaDataBuffer[0..4]); + var type = FormatType(typeId, metaDataBuffer[0..4]); + var attr1 = BinaryPrimitives.ReadUInt32LittleEndian(metaDataBuffer[4..8]); + var attr2 = BinaryPrimitives.ReadUInt32LittleEndian(metaDataBuffer[8..12]); + var fileLength = BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[12..16]); + var attr3 = BinaryPrimitives.ReadUInt32LittleEndian(metaDataBuffer[16..20]); + var name = ReadNResName(metaDataBuffer[20..56]); + var offset = BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[56..60]); + var sortIndex = BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[60..64]); - for (int j = 0; j < 4; j++) + if (offset < 16 || fileLength < 0 || (long)offset + fileLength > directoryOffset) { - if (!char.IsLetterOrDigit((char)metaDataBuffer[j])) - { - type += metaDataBuffer[j] - .ToString("X2") + " "; - } - else - { - type += (char)metaDataBuffer[j]; - } + return new NResParseResult(null, $"Запись '{name}' выходит за границы data region"); } - - var type2 = BinaryPrimitives.ReadUInt32LittleEndian(metaDataBuffer.Slice(4)); - - type = type.Trim(); - elements.Add( - new ListMetadataItem( - FileType: type, - ElementCount: type2, - Magic1: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[8..12]), - FileLength: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[12..16]), - ElementSize: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[16..20]), - FileName: Encoding.ASCII.GetString(metaDataBuffer[20..40]).TrimEnd('\0'), - Magic3: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[40..44]), - Magic4: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[44..48]), - Magic5: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[48..52]), - Magic6: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[52..56]), - OffsetInFile: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[56..60]), - Index: BinaryPrimitives.ReadInt32LittleEndian(metaDataBuffer[60..64]) - ) - ); + elements.Add(new ListMetadataItem( + typeId, + type, + attr1, + unchecked((int)attr2), + fileLength, + unchecked((int)attr3), + name, + offset, + sortIndex, + i)); metaDataBuffer.Clear(); } @@ -99,4 +107,34 @@ public static class NResParser ) ); } -} \ No newline at end of file + + private static string FormatType(uint typeId, ReadOnlySpan bytes) + { + bool formattable = true; + foreach (var b in bytes) + { + if (!char.IsLetterOrDigit((char)b)) + { + formattable = false; + break; + } + } + if (!formattable) + { + return Encoding.ASCII.GetString(bytes); + } + + return string.Join(" ", bytes.ToArray().Select(x => x.ToString("X2"))); + } + + private static string ReadNResName(ReadOnlySpan raw) + { + var nul = raw.IndexOf((byte)0); + if (nul < 0) + { + return Encoding.ASCII.GetString(raw); + } + + return Encoding.ASCII.GetString(raw[..nul]); + } +} diff --git a/NResUI/App.cs b/NResUI/App.cs index d7989f4..9c0461d 100644 --- a/NResUI/App.cs +++ b/NResUI/App.cs @@ -12,10 +12,10 @@ namespace NResUI; public class App { - public GL GL { get; set; } - public IInputContext Input { get; set; } + public GL GL { get; set; } = null!; + public IInputContext Input { get; set; } = null!; - public static App Instance; + public static App Instance = null!; private static bool _dockspaceOpen = true; private static bool _optFullscreenPersistant = true; @@ -25,7 +25,7 @@ public class App public ImFontPtr OpenSansFont; - private List _imGuiPanels; + private List _imGuiPanels = []; public App() { @@ -151,4 +151,4 @@ public class App public void OnKeyReleased(Key key) { } - } \ No newline at end of file + } diff --git a/NResUI/ImGuiUI/NResExplorerPanel.cs b/NResUI/ImGuiUI/NResExplorerPanel.cs index b9d38ee..d2629bb 100644 --- a/NResUI/ImGuiUI/NResExplorerPanel.cs +++ b/NResUI/ImGuiUI/NResExplorerPanel.cs @@ -70,7 +70,7 @@ public class NResExplorerPanel : IImGuiPanel ImGui.SameLine(); ImGui.Text(_viewModel.Archive.Header.TotalFileLengthBytes.ToString()); - if (ImGui.BeginTable("content", 12, + if (ImGui.BeginTable("content", 9, ImGuiTableFlags.Borders | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoHostExtendX)) { ImGui.TableSetupColumn("Тип файла"); @@ -79,12 +79,9 @@ public class NResExplorerPanel : IImGuiPanel ImGui.TableSetupColumn("Длина файла в байтах"); ImGui.TableSetupColumn("Размер элемента"); ImGui.TableSetupColumn("Имя файла"); - ImGui.TableSetupColumn("Magic3"); - ImGui.TableSetupColumn("Magic4"); - ImGui.TableSetupColumn("Magic5"); - ImGui.TableSetupColumn("Magic6"); ImGui.TableSetupColumn("Смещение в байтах"); - ImGui.TableSetupColumn("Индекс в файле"); + ImGui.TableSetupColumn("SortIndex"); + ImGui.TableSetupColumn("DirectoryIndex"); ImGui.TableHeadersRow(); @@ -124,26 +121,6 @@ public class NResExplorerPanel : IImGuiPanel ImGui.TableNextColumn(); ImGui.Text(_viewModel.Archive.Files[i].FileName); ImGui.TableNextColumn(); - ImGui.Text( - _viewModel.Archive.Files[i] - .Magic3.ToString() - ); - ImGui.TableNextColumn(); - ImGui.Text( - _viewModel.Archive.Files[i] - .Magic4.ToString() - ); - ImGui.TableNextColumn(); - ImGui.Text( - _viewModel.Archive.Files[i] - .Magic5.ToString() - ); - ImGui.TableNextColumn(); - ImGui.Text( - _viewModel.Archive.Files[i] - .Magic6.ToString() - ); - ImGui.TableNextColumn(); ImGui.Text( _viewModel.Archive.Files[i] .OffsetInFile.ToString() @@ -151,7 +128,12 @@ public class NResExplorerPanel : IImGuiPanel ImGui.TableNextColumn(); ImGui.Text( _viewModel.Archive.Files[i] - .Index.ToString() + .SortIndex.ToString() + ); + ImGui.TableNextColumn(); + ImGui.Text( + _viewModel.Archive.Files[i] + .DirectoryIndex.ToString() ); } @@ -309,4 +291,4 @@ public class NResExplorerPanel : IImGuiPanel ImGui.End(); } } -} \ No newline at end of file +} diff --git a/NResUI/ImGuiUI/TexmExplorer.cs b/NResUI/ImGuiUI/TexmExplorer.cs index 722453d..8c59f78 100644 --- a/NResUI/ImGuiUI/TexmExplorer.cs +++ b/NResUI/ImGuiUI/TexmExplorer.cs @@ -166,6 +166,43 @@ public class TexmExplorer : IImGuiPanel } ImGui.Image((IntPtr) glTexture.GlTexture, imageSize); + + var imageHovered = ImGui.IsItemHovered(); + + if (imageHovered && index < _viewModel.RgbaBytesByMipmap.Count) + { + var mousePos = ImGui.GetMousePos(); + + var scale = _viewModel.DoubleSize ? 2.0f : 1.0f; + var relativePos = (mousePos - screenPos) / scale; + + var pixelX = (int)MathF.Floor(relativePos.X); + var pixelY = (int)MathF.Floor(relativePos.Y); + + pixelX = (int)Math.Clamp(pixelX, 0, glTexture.Width - 1); + pixelY = (int)Math.Clamp(pixelY, 0, glTexture.Height - 1); + + var rgbaBytes = _viewModel.RgbaBytesByMipmap[index]; + var byteIndex = (pixelY * glTexture.Width + pixelX) * 4; + + if (byteIndex + 3 < rgbaBytes.Length) + { + var r = rgbaBytes[byteIndex + 0]; + var g = rgbaBytes[byteIndex + 1]; + var b = rgbaBytes[byteIndex + 2]; + var a = rgbaBytes[byteIndex + 3]; + + ImGui.BeginTooltip(); + + ImGui.Text($"Mipmap: {index}"); + ImGui.Text($"Pixel: X={pixelX}, Y={pixelY}"); + ImGui.Text($"RGBA: {r}, {g}, {b}, {a}"); + ImGui.Text($"HEX: #{r:X2}{g:X2}{b:X2}{a:X2}"); + + ImGui.EndTooltip(); + } + } + ImGui.SameLine(); if (_viewModel.ViewPages && _viewModel.TexmFile.Pages is not null) @@ -180,18 +217,6 @@ public class TexmExplorer : IImGuiPanel ); } } - - if (ImGui.IsItemHovered()) - { - var mousePos = ImGui.GetMousePos(); - var relativePos = (mousePos - screenPos) / (_viewModel.DoubleSize - ? 2 - : 1); - - ImGui.Text("Hovering over: "); - ImGui.SameLine(); - ImGui.Text(relativePos.ToString()); - } } } } diff --git a/NResUI/Models/TexmExplorerViewModel.cs b/NResUI/Models/TexmExplorerViewModel.cs index fa43519..1fb1bdd 100644 --- a/NResUI/Models/TexmExplorerViewModel.cs +++ b/NResUI/Models/TexmExplorerViewModel.cs @@ -13,6 +13,7 @@ public class TexmExplorerViewModel public string? Path { get; set; } public List GlTextures { get; set; } = []; + public List RgbaBytesByMipmap { get; set; } = []; private bool _glTexturesDirty = false; public bool IsWhiteBgEnabled; @@ -48,11 +49,14 @@ public class TexmExplorerViewModel } GlTextures.Clear(); + RgbaBytesByMipmap.Clear(); - for (var i = 0; i < TexmFile!.Header.MipmapCount; i++) + for (var i = 0; i < TexmFile.Header.MipmapCount; i++) { var bytes = TexmFile.GetRgba32BytesFromMipmap(i, out var width, out var height); + RgbaBytesByMipmap.Add(bytes); + var glTexture = new OpenGlTexture( gl, width, diff --git a/NResUI/OpenGlTexture.cs b/NResUI/OpenGlTexture.cs index 0983599..42a474f 100644 --- a/NResUI/OpenGlTexture.cs +++ b/NResUI/OpenGlTexture.cs @@ -19,7 +19,7 @@ namespace NResUI public static float? MaxAniso; private readonly GL _gl; - public readonly string Name; + public readonly string Name = string.Empty; public readonly uint GlTexture; public readonly uint Width, Height; public readonly uint MipmapLevels; @@ -152,4 +152,4 @@ namespace NResUI _gl.DeleteTexture(GlTexture); } } -} \ No newline at end of file +} diff --git a/PalLib/PalFile.cs b/PalLib/PalFile.cs index 082fc9c..587c9d9 100644 --- a/PalLib/PalFile.cs +++ b/PalLib/PalFile.cs @@ -3,23 +3,12 @@ using SixLabors.ImageSharp.PixelFormats; namespace PalLib; -/// -/// PAL файл по сути это indexed текстура (1024 байт - 256 цветов lookup + 4 байта "Ipol" и затем 256x256 индексов в lookup) -/// -public class PalFile +/// PAL файл: indexed texture с lookup таблицей, подписью Ipol и индексами. +/// Имя PAL файла. +/// [0x0000..0x0400] 256 цветов lookup (1024 байта). +/// [0x0404..0x10404] 256x256 индексов в lookup. +public record class PalFile(string FileName, byte[] Palette, byte[] Indices) { - public required string FileName { get; set; } - - /// - /// 256 цветов lookup (1024 байт) - /// - public required byte[] Palette { get; set; } - - /// - /// 256x256 индексов в lookup - /// - public required byte[] Indices { get; set; } - public void SaveAsPng(string outputPath) { const int width = 256; @@ -31,16 +20,12 @@ public class PalFile { var index = Indices[i]; - // Palette is 256 colors * 4 bytes (ARGB usually, based on TexmLib) - // TexmLib: r = lookup[i*4+0], g = lookup[i*4+1], b = lookup[i*4+2], a = lookup[i*4+3] - // Assuming same format here. - - // since PAL is likely directx related, the format is is likely BGRA + // PAL, вероятно, связан с DirectX, поэтому порядок каналов похож на BGRA. var b = Palette[index * 4 + 0]; var g = Palette[index * 4 + 1]; var r = Palette[index * 4 + 2]; - var a = Palette[index * 4 + 3]; // Alpha? Or is it unused/padding? TexmLib sets alpha to 255 manually for indexed. + var a = Palette[index * 4 + 3]; // Альфа или padding; ниже пока используется непрозрачность. rgbaBytes[i * 4 + 0] = r; rgbaBytes[i * 4 + 1] = g; diff --git a/PalLib/PalParser.cs b/PalLib/PalParser.cs index efcc815..1b9cb4a 100644 --- a/PalLib/PalParser.cs +++ b/PalLib/PalParser.cs @@ -27,11 +27,6 @@ public class PalParser var indices = new byte[65536]; stream.ReadExactly(indices, 0, 65536); - return new PalFile - { - FileName = filename, - Palette = palette, - Indices = indices - }; + return new PalFile(filename, palette, indices); } } diff --git a/ParkanPlayground.slnx b/ParkanPlayground.slnx index c61ce41..1648a45 100644 --- a/ParkanPlayground.slnx +++ b/ParkanPlayground.slnx @@ -3,18 +3,17 @@ - - - - + + + - + - + diff --git a/ParkanPlayground/Effects/FxidReader.cs b/ParkanPlayground/Effects/FxidReader.cs index a3c0977..99be463 100644 --- a/ParkanPlayground/Effects/FxidReader.cs +++ b/ParkanPlayground/Effects/FxidReader.cs @@ -25,12 +25,17 @@ public static class FxidReader { EffectHeader h; h.ComponentCount = br.ReadUInt32(); - h.Unknown1 = br.ReadUInt32(); + h.TimeMode = br.ReadUInt32(); h.Duration = br.ReadSingle(); - h.Unknown2 = br.ReadSingle(); + h.PhaseJitter = br.ReadSingle(); h.Flags = br.ReadUInt32(); - h.Unknown3 = br.ReadUInt32(); - h.Reserved = br.ReadBytes(24); + h.SettingsId = br.ReadUInt32(); + h.RandShiftX = br.ReadSingle(); + h.RandShiftY = br.ReadSingle(); + h.RandShiftZ = br.ReadSingle(); + h.PivotX = br.ReadSingle(); + h.PivotY = br.ReadSingle(); + h.PivotZ = br.ReadSingle(); h.ScaleX = br.ReadSingle(); h.ScaleY = br.ReadSingle(); h.ScaleZ = br.ReadSingle(); diff --git a/ParkanPlayground/Effects/FxidTypes.cs b/ParkanPlayground/Effects/FxidTypes.cs index 42b85af..b0b4f70 100644 --- a/ParkanPlayground/Effects/FxidTypes.cs +++ b/ParkanPlayground/Effects/FxidTypes.cs @@ -7,18 +7,39 @@ namespace ParkanPlayground.Effects; /// Parsed from CEffect_InitFromDef: defines component count, global duration/flags, /// some unknown control fields, and the uniform scale vector applied to the effect. /// -public struct EffectHeader +public record struct EffectHeader { + /// FXID payload offset 0x00: command count. public uint ComponentCount; - public uint Unknown1; + /// FXID payload offset 0x04: time mode used to compute effect alpha. + public uint TimeMode; + /// FXID payload offset 0x08: effect duration in seconds. public float Duration; - public float Unknown2; + /// FXID payload offset 0x0C: random phase shift amplitude. + public float PhaseJitter; + /// FXID payload offset 0x10: effect behavior flags. public uint Flags; - public uint Unknown3; - public byte[] Reserved; // 24 bytes + /// FXID payload offset 0x14: settings/profile id. + public uint SettingsId; + /// FXID payload offset 0x18: random spatial shift X. + public float RandShiftX; + /// FXID payload offset 0x1C: random spatial shift Y. + public float RandShiftY; + /// FXID payload offset 0x20: random spatial shift Z. + public float RandShiftZ; + /// FXID payload offset 0x24: local pivot X. + public float PivotX; + /// FXID payload offset 0x28: local pivot Y. + public float PivotY; + /// FXID payload offset 0x2C: local pivot Z. + public float PivotZ; + /// FXID payload offset 0x30: base scale X. public float ScaleX; + /// FXID payload offset 0x34: base scale Y. public float ScaleY; + /// FXID payload offset 0x38: base scale Z. public float ScaleZ; + } /// @@ -26,7 +47,7 @@ public struct EffectHeader /// Used by CBillboardComponent_Initialize/Update/Render to drive size/color/alpha /// curves and sample scattering within a 3D extent volume. /// -public struct BillboardComponentData +public record struct BillboardComponentData { public uint TypeAndFlags; // type (low byte) and flags as seen in CEffect_InitFromDef public float Unknown04; // mode / flag-like float, semantics not fully clear @@ -56,7 +77,7 @@ public struct BillboardComponentData /// Used by CSoundComponent_Initialize/Update to drive positional audio, playback /// window, and scalar ranges (e.g. volume / pitch), plus a 0x40-byte sound name tail. /// -public struct SoundComponentData +public record struct SoundComponentData { public uint TypeAndFlags; // component type and flags public uint PlayMode; // playback mode (looping, one-shot, etc.) @@ -79,7 +100,7 @@ public struct SoundComponentData /// Prefix layout matches BillboardComponentData and is used to allocate a grid of /// particle objects; the 0x38-byte tail is passed into CFxManager_LoadTexture. /// -public struct AnimParticleComponentData +public record struct AnimParticleComponentData { public uint TypeAndFlags; // type (low byte) and flags as seen in CEffect_InitFromDef public float Unknown04; // mode / flag-like float, semantics not fully clear @@ -109,7 +130,7 @@ public struct AnimParticleComponentData /// Shares the same prefix layout as BillboardComponentData, including extents and /// radius/exponent triplets, but uses a 0x3C-byte tail passed to CFxManager_LoadTexture. /// -public struct AnimBillboardComponentData +public record struct AnimBillboardComponentData { public uint TypeAndFlags; // type (low byte) and flags as seen in CEffect_InitFromDef public float Unknown04; // mode / flag-like float, semantics not fully clear @@ -139,7 +160,7 @@ public struct AnimBillboardComponentData /// CTrailComponent_Initialize interprets this as segment count, width/alpha/UV /// ranges, timing, and a shared texture name at +0x30. /// -public struct TrailComponentData +public record struct TrailComponentData { public uint TypeAndFlags; // component type and flags public byte[] Unknown04To10; // 0x10 bytes at +4..+0x13, used only indirectly; types unknown @@ -157,7 +178,7 @@ public struct TrailComponentData /// Simple point component definition (type 6). /// Definition block is just the 4-byte typeAndFlags header; no extra data on disk. /// -public struct PointComponentData +public record struct PointComponentData { public uint TypeAndFlags; // component type and flags; definition block has no payload } @@ -167,7 +188,7 @@ public struct PointComponentData /// Shares the same 0xC8-byte prefix layout as AnimParticleComponentData (type 3), /// followed by two dwords of plane-specific data. /// -public struct PlaneComponentData +public record struct PlaneComponentData { public AnimParticleComponentData Base; // shared 0xC8-byte prefix: time window, sample counts, extents, curves public uint ExtraPlaneParam0; // plane-specific parameter, semantics not yet reversed @@ -180,7 +201,7 @@ public struct PlaneComponentData /// time window, instance count, spatial extents/axes, radius triplets, and a /// 0x40-byte texture name tail. /// -public struct ModelComponentData +public record struct ModelComponentData { public uint TypeAndFlags; // component type and flags public byte[] Unk04; // 0x14-byte blob at +0x04..+0x17, purpose unclear @@ -206,7 +227,7 @@ public struct ModelComponentData /// Layout derived from CAnimModelComponent_Initialize: time params, direction vectors, /// radius triplets, extent vectors, and a 0x48-byte texture name tail. /// -public struct AnimModelComponentData +public record struct AnimModelComponentData { public uint TypeAndFlags; // component type and flags public float AnimSpeed; // animation speed multiplier at +0x04 @@ -230,7 +251,7 @@ public struct AnimModelComponentData /// Shares the same 0xCC-byte prefix layout as AnimBillboardComponentData (type 4), /// followed by one dword of cube-specific data. /// -public struct CubeComponentData +public record struct CubeComponentData { public AnimBillboardComponentData Base; // shared 0xCC-byte prefix: billboard-style time window, extents, curves public uint ExtraCubeParam0; // cube-specific parameter, semantics not yet reversed diff --git a/ParkanPlayground/MSH_FORMAT.md b/ParkanPlayground/MSH_FORMAT.md index 630e25f..c317b69 100644 --- a/ParkanPlayground/MSH_FORMAT.md +++ b/ParkanPlayground/MSH_FORMAT.md @@ -25,21 +25,21 @@ MSH файлы — это NRes архивы, содержащие несколь | Тип | Название | Размер элемента | Описание | |:---:|----------|:---------------:|----------| -| 01 | Pieces | 38 (0x26) | Части меша / тайлы с LOD-ссылками | -| 02 | Submeshes | 68 (0x44) | LOD части с баундинг-боксами | -| 03 | Vertices | 12 (0x0C) | Позиции вершин (Vector3) | -| 04 | неизвестно | 4 | (неизвестно) | -| 05 | неизвестно | 4 | (неизвестно) | -| 06 | Indices | 2 | Индексы вершин треугольников (только Модель) | -| 07 | неизвестно | 16 | (только Модель) | -| 08 | Animations | 4 | Кейфреймы анимации меша | -| 0A | ExternalRefs | переменный | Внешние ссылки на меши (строки) | +| 01 | Node table | 38 (0x26), редко 24 | Узлы модели / тайлы; старое имя: Pieces | +| 02 | Header + slots | 0x8C + n*68 | Общий заголовок и slot records; старое имя: Submeshes | +| 03 | Positions | 12 (0x0C) | Позиции вершин (Vector3); старое имя: Vertices | +| 04 | PackedNormals | 4 | `int8[4]`, normal = clamp(component / 127.0, -1..1) | +| 05 | PackedUV0 | 4 | `int16[2]`, uv = component / 1024.0 | +| 06 | Index buffer | 2 | Индексы вершин треугольников | +| 07 | Tri descriptors | 16 | Описатели треугольников для коллизии/пикинга | +| 08 | AnimKeyPool | 24 | Кейфреймы анимации меша | +| 0A | Node strings | переменный | Строки узлов; старое имя: ExternalRefs | | 0B | неизвестно | 4 | неизвестно (только Ландшафт) | -| 0D | неизвестно | 20 (0x14) | неизвестно (только Модель) | +| 0D | Batch table | 20 (0x14) | Батчи рендера; FParkan Res13 decimal | | 0E | неизвестно | 4 | неизвестно (только Ландшафт) | | 12 | MicrotextureMap | 4 | неизвестно | -| 13 | ShortAnims | 2 | Короткие индексы анимаций | -| 15 | неизвестно | 28 (0x1C) | неизвестно | +| 13 | AnimMap | 2 | Карта кадров анимации, на нее указывает `AnimMapStart` из 0x01 | +| 15 | TerrainTriangle table | 28 (0x1C) | Terrain-гипотеза | --- @@ -52,15 +52,15 @@ MSH файлы — это NRes архивы, содержащие несколь │ └─► Lod[n] ──► Компонент 02 (индекс сабмеша) │ - ├─► StartIndexIn07 ──► Компонент 07 (данные на треугольник) + ├─► TriStart:TriCount ──► Компонент 07 (данные на треугольник) │ - └─► StartOffsetIn0d:ByteLengthIn0D ──► Компонент 0D (батчи) + └─► BatchStart:BatchCount ──► Компонент 0D (батчи) │ - ├─► IndexInto06:CountOf06 ──► Компонент 06 (индексы) + ├─► IndexStart:IndexCount ──► Компонент 06 (индексы) │ │ │ └─► Компонент 03 (вершины) │ - └─► IndexInto03 (базовое смещение вершины) + └─► BaseVertex (базовое смещение вершины) ``` ### Ландшафт (террейн) @@ -70,38 +70,37 @@ MSH файлы — это NRes архивы, содержащие несколь │ └─► Lod[n] ──► Компонент 02 (индекс сабмеша) │ - └─► StartIndexIn07:CountIn07 ──► Компонент 15 (треугольники) + └─► TriStart:TriCount ──► Компонент 15 (треугольники) │ └─► Vertex1/2/3Index ──► Компонент 03 (вершины) - └─► StartIndexIn07:CountIn07 ──► Компонент 0B (материалы, параллельно 15) + └─► TriStart:TriCount ──► Компонент 0B (материалы, параллельно 15) ``` -**Важно:** В ландшафтных мешах поля `StartIndexIn07` и `CountIn07` в Компоненте 02 +**Важно:** В ландшафтных мешах поля `TriStart` и `TriCount` в Компоненте 02 используются для индексации в Компонент 15 (треугольники), а не в Компонент 07. --- ## Структуры компонентов -### Компонент 01 - Pieces (0x26 = 38 байт) +### Компонент 0x01 - Node table (0x26 = 38 байт) -Определяет части меша (для моделей) или тайлы террейна (для ландшафтов). +Определяет узлы модели или тайлы terrain. Старое локальное имя: Pieces / SubMesh. | Смещение | Размер | Тип | Поле | Описание | |:--------:|:------:|:---:|------|----------| -| 0x00 | 1 | byte | Type1 | Флаги типа части | -| 0x01 | 1 | byte | Type2 | Дополнительные флаги | -| 0x02 | 2 | int16 | ParentIndex | Индекс родителя (-1 = корень) | -| 0x04 | 2 | int16 | OffsetIntoFile13 | Смещение в короткие анимации | -| 0x06 | 2 | int16 | IndexInFile08 | Индекс в анимации | -| 0x08 | 30 | ushort[15] | Lod | Индексы сабмешей по LOD-уровням (0xFFFF = не используется) | +| 0x00 | 2 | uint16 | Header0 | Заголовочное слово узла; старые имена: Type1 + Type2 | +| 0x02 | 2 | uint16 | ParentOrLink | Индекс родителя/ссылка; старый локальный тип int16 показывал 0xFFFF как -1 | +| 0x04 | 2 | uint16 | AnimMapStart | Начало блока в 0x13 или 0xFFFF; старое имя: OffsetIntoFile13 | +| 0x06 | 2 | uint16 | FallbackKey | Индекс fallback-ключа в 0x08; старое имя: IndexInFile08 | +| 0x08 | 30 | ushort[15] | SlotIndex | Индексы slot в 0x02 по формуле `lod * 5 + group`; старое имя: Lod | **Ландшафт:** 256 тайлов в сетке 16×16. Каждый тайл имеет 2 LOD (индексы 0-255 и 256-511). --- -### Компонент 02 - Submeshes (Заголовок: 0x8C = 140 байт, Элемент: 0x44 = 68 байт) +### Компонент 0x02 - Header + slots (Заголовок: 0x8C = 140 байт, slot: 0x44 = 68 байт) #### Заголовок (140 байт) @@ -118,15 +117,15 @@ MSH файлы — это NRes архивы, содержащие несколь | Смещение | Размер | Тип | Поле | Описание | |:--------:|:------:|:---:|------|----------| -| 0x00 | 2 | ushort | StartIndexIn07 | **Модель:** Начальный индекс в Компоненте 07
**Ландшафт:** Начальный индекс треугольника в Компоненте 15 | -| 0x02 | 2 | ushort | CountIn07 | **Модель:** Количество в Компоненте 07
**Ландшафт:** Количество треугольников | -| 0x04 | 2 | ushort | StartOffsetIn0d | Начальное смещение в Компоненте 0D (только Модель) | -| 0x06 | 2 | ushort | ByteLengthIn0D | Количество батчей в Компоненте 0D (только Модель) | +| 0x00 | 2 | ushort | TriStart | Начальный индекс в Компоненте 07; в landscape-tooling может указывать в 15 | +| 0x02 | 2 | ushort | TriCount | Количество записей в Компоненте 07; в landscape-tooling может быть count для 15 | +| 0x04 | 2 | ushort | BatchStart | Начальное смещение в Компоненте 0D (только Модель) | +| 0x06 | 2 | ushort | BatchCount | Количество батчей в Компоненте 0D (только Модель) | | 0x08 | 12 | Vector3 | LocalMinimum | Минимум локального баундинг-бокса | | 0x14 | 12 | Vector3 | LocalMaximum | Максимум локального баундинг-бокса | | 0x20 | 12 | Vector3 | Center | Центр сабмеша | -| 0x2C | 12 | Vector3 | Vector4 | Неизвестно | -| 0x38 | 12 | Vector3 | Vector5 | Неизвестно | +| 0x2C | 4 | float | SphereRadius | Радиус bounding sphere; старый `Vector4` был overlay-гипотезой | +| 0x30 | 20 | uint32[5] | Opaque | Непонятый tail, сохранять 1:1; старый `Vector5` был overlay-гипотезой | --- @@ -147,20 +146,20 @@ MSH файлы — это NRes архивы, содержащие несколь --- -### Компонент 07 - Triangle Data (0x10 = 16 байт) - Только Модель +### Компонент 0x07 - Tri descriptors (0x10 = 16 байт) -Данные рендеринга на каждый треугольник. +Описатели треугольников для коллизии/пикинга. | Смещение | Размер | Тип | Поле | Описание | |:--------:|:------:|:---:|------|----------| -| 0x00 | 2 | ushort | Flags | Флаги рендера | -| 0x02 | 2 | ushort | Magic02 | Неизвестно | -| 0x04 | 2 | ushort | Magic04 | Неизвестно | -| 0x06 | 2 | ushort | Magic06 | Неизвестно | -| 0x08 | 2 | int16 | OffsetX | Нормализованный X (÷32767 для -1..1) | -| 0x0A | 2 | int16 | OffsetY | Нормализованный Y (÷32767 для -1..1) | -| 0x0C | 2 | int16 | OffsetZ | Нормализованный Z (÷32767 для -1..1) | -| 0x0E | 2 | ushort | Magic14 | Неизвестно | +| 0x00 | 2 | ushort | TriFlags | Флаги треугольника; старое имя: Flags | +| 0x02 | 2 | ushort | Link0 | Связь/opaque поле 0; старое имя: Magic02 | +| 0x04 | 2 | ushort | Link1 | Связь/opaque поле 1; старое имя: Magic04 | +| 0x06 | 2 | ushort | Link2 | Связь/opaque поле 2; старое имя: Magic06 | +| 0x08 | 2 | int16 | NormalX | Упакованная X-компонента нормали; старое имя: OffsetX | +| 0x0A | 2 | int16 | NormalY | Упакованная Y-компонента нормали; старое имя: OffsetY | +| 0x0C | 2 | int16 | NormalZ | Упакованная Z-компонента нормали; старое имя: OffsetZ | +| 0x0E | 2 | ushort | SelectorPacked | Три 2-битных селектора; `3` трактуется как `0xFFFF`; старое имя: Magic14 | --- @@ -175,40 +174,38 @@ MSH файлы — это NRes архивы, содержащие несколь --- -### Компонент 0D - Draw Batches (0x14 = 20 байт) - Только Модель +### Компонент 0x0D - Batch table (0x14 = 20 байт) -Определяет батчи вызовов отрисовки. +Определяет батчи вызовов отрисовки. В терминах FParkan это Res13 decimal. | Смещение | Размер | Тип | Поле | Описание | |:--------:|:------:|:---:|------|----------| -| 0x00 | 2 | ushort | Flags | Флаги батча | -| 0x02 | 2 | - | Padding | - | -| 0x04 | 1 | byte | Magic04 | Неизвестно | -| 0x05 | 1 | byte | Magic05 | Неизвестно | -| 0x06 | 2 | ushort | Magic06 | Неизвестно | -| 0x08 | 2 | ushort | CountOf06 | Количество индексов для отрисовки | -| 0x0A | 4 | int32 | IndexInto06 | Начальный индекс в Компоненте 06 | -| 0x0E | 2 | ushort | CountOf03 | Количество вершин | -| 0x10 | 4 | int32 | IndexInto03 | Базовое смещение вершины в Компоненте 03 | +| 0x00 | 2 | ushort | BatchFlags / Flags.low | Флаги батча | +| 0x02 | 2 | ushort | MaterialIndex / Flags.high | Индекс material slot | +| 0x04 | 2 | ushort | Opaque4 | Opaque, старое имя `TriangleCount` не подтверждено | +| 0x06 | 2 | ushort | Opaque6 | Opaque | +| 0x08 | 2 | ushort | IndexCount | Количество индексов для отрисовки в 0x06 | +| 0x0A | 4 | uint32 | IndexStart | Начальный индекс в Компоненте 06 | +| 0x0E | 2 | ushort | Opaque14 | Opaque, старое имя `CountOf03` не подтверждено | +| 0x10 | 4 | uint32 | BaseVertex | Базовое смещение вершины в Компоненте 03 | --- -### Компонент 15 - Triangles (0x1C = 28 байт) +### Компонент 0x15 - TerrainTriangle table (0x1C = 28 байт) -Прямые определения треугольников. Используется и Моделью и Ландшафтом, -но только Ландшафт использует их напрямую для рендеринга. +Прямые определения terrain-треугольников. Это hex-компонент 0x15 проекта, не FParkan Res15 decimal. | Смещение | Размер | Тип | Поле | Описание | |:--------:|:------:|:---:|------|----------| | 0x00 | 4 | uint32 | Flags | Флаги треугольника (0x20000 = коллизия) | -| 0x04 | 4 | uint32 | MaterialData | Данные материала (см. ниже) | +| 0x04 | 4 | uint32 | MaterialData | Данные материала; старое имя: Magic04 | | 0x08 | 2 | ushort | Vertex1Index | Индекс первой вершины | | 0x0A | 2 | ushort | Vertex2Index | Индекс второй вершины | | 0x0C | 2 | ushort | Vertex3Index | Индекс третьей вершины | -| 0x0E | 4 | uint32 | Magic0E | Неизвестно | -| 0x12 | 4 | uint32 | Magic12 | Неизвестно | -| 0x16 | 4 | uint32 | Magic16 | Неизвестно | -| 0x1A | 2 | ushort | Magic1A | Неизвестно | +| 0x0E | 4 | uint32 | Opaque0E | Opaque; старое имя: Magic0E | +| 0x12 | 4 | uint32 | Opaque12 | Opaque; старое имя: Magic12 | +| 0x16 | 4 | uint32 | Opaque16 | Opaque; старое имя: Magic16 | +| 0x1A | 2 | ushort | Opaque1A | Opaque; старое имя: Magic1A | #### MaterialData (0x04) - Структура материала @@ -298,7 +295,7 @@ var type = MshConverter.DetectMeshType(archive); | Файл | Используется для | Треугольники в Comp15 | |------|------------------|----------------------| -| `Land1.wea` | LOD0 (высокая детализация) | Первые N (сумма CountIn07 для LOD0) | +| `Land1.wea` | LOD0 (высокая детализация) | Первые N (сумма TriCount для LOD0) | | `Land2.wea` | LOD1 (низкая детализация) | Остальные | ### Пример (SC_1) diff --git a/ParkanPlayground/Msh01.cs b/ParkanPlayground/Msh01.cs deleted file mode 100644 index 60edefa..0000000 --- a/ParkanPlayground/Msh01.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Buffers.Binary; -using NResLib; - -namespace ParkanPlayground; - -public static class Msh01 -{ - public static Msh01Component ReadComponent(FileStream mshFs, NResArchive archive) - { - var headerFileEntry = archive.Files.FirstOrDefault(x => x.FileType == "01 00 00 00"); - - if (headerFileEntry is null) - { - throw new Exception("Archive doesn't contain header file (01)"); - } - - var data = new byte[headerFileEntry.ElementCount * headerFileEntry.ElementSize]; - mshFs.Seek(headerFileEntry.OffsetInFile, SeekOrigin.Begin); - mshFs.ReadExactly(data, 0, data.Length); - - var dataSpan = data.AsSpan(); - - var elements = new List((int)headerFileEntry.ElementCount); - for (var i = 0; i < headerFileEntry.ElementCount; i++) - { - var element = new SubMesh() - { - Type1 = dataSpan[i * headerFileEntry.ElementSize + 0], - Type2 = dataSpan[i * headerFileEntry.ElementSize + 1], - ParentIndex = - BinaryPrimitives.ReadInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 2)), - OffsetIntoFile13 = - BinaryPrimitives.ReadInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 4)), - IndexInFile08 = - BinaryPrimitives.ReadInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 6)) - }; - - element.Lod[0] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 8)); - element.Lod[1] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 10)); - element.Lod[2] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 12)); - element.Lod[3] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 14)); - element.Lod[4] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 16)); - element.Lod[5] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 18)); - element.Lod[6] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 20)); - element.Lod[7] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 22)); - element.Lod[8] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 24)); - element.Lod[9] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 26)); - element.Lod[10] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 28)); - element.Lod[11] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 30)); - element.Lod[12] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 32)); - element.Lod[13] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 34)); - element.Lod[14] = BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(i * headerFileEntry.ElementSize + 36)); - elements.Add(element); - } - - return new Msh01Component() - { - Elements = elements - }; - } - - - public class Msh01Component - { - public List Elements { get; set; } - } - - public class SubMesh - { - public byte Type1 { get; set; } - public byte Type2 { get; set; } - public short ParentIndex { get; set; } - public short OffsetIntoFile13 { get; set; } - public short IndexInFile08 { get; set; } - public ushort[] Lod { get; set; } = new ushort[15]; - } -} \ No newline at end of file diff --git a/ParkanPlayground/Msh02.cs b/ParkanPlayground/Msh02.cs deleted file mode 100644 index 48e502b..0000000 --- a/ParkanPlayground/Msh02.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Buffers.Binary; -using Common; -using NResLib; - -namespace ParkanPlayground; - -public static class Msh02 -{ - public static Msh02Component ReadComponent(FileStream mshFs, NResArchive archive) - { - var fileEntry = archive.Files.FirstOrDefault(x => x.FileType == "02 00 00 00"); - - if (fileEntry is null) - { - throw new Exception("Archive doesn't contain 02 component"); - } - - var data = new byte[fileEntry.FileLength]; - mshFs.Seek(fileEntry.OffsetInFile, SeekOrigin.Begin); - mshFs.ReadExactly(data, 0, data.Length); - - var header = data.AsSpan(0, 0x8c); // 140 bytes header - - var center = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x60)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x64)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x68)) - ); - var centerW = BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x6c)); - - var bb = new BoundingBox(); - bb.BottomFrontLeft = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(4)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(8)) - ); - bb.BottomFrontRight = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(12)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(16)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(20)) - ); - bb.BottomBackRight = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(24)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(28)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(32)) - ); - bb.BottomBackLeft = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(36)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(40)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(44)) - ); - bb.TopFrontLeft = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(48)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(52)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(56)) - ); - bb.TopFrontRight = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(60)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(64)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(68)) - ); - bb.TopBackRight = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(72)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(76)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(80)) - ); - bb.TopBackLeft = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(84)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(88)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(92)) - ); - - var bottom = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(112)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(116)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(120)) - ); - - var top = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(124)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(128)), - BinaryPrimitives.ReadSingleLittleEndian(header.Slice(132)) - ); - - var xyRadius = BinaryPrimitives.ReadSingleLittleEndian(header.Slice(136)); - - - List elements = new List(); - var skippedHeader = data.AsSpan(0x8c); // skip header - for (var i = 0; i < fileEntry.ElementCount; i++) - { - var element = new Msh02Element(); - element.StartIndexIn07 = - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 0)); - element.CountIn07 = - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 2)); - element.StartOffsetIn0d = - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 4)); - element.ByteLengthIn0D = - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 6)); - element.LocalMinimum = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 8)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 12)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 16)) - ); - element.LocalMaximum = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 20)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 24)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 28)) - ); - element.Center = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 32)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 36)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 40)) - ); - element.Vector4 = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 44)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 48)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 52)) - ); - element.Vector5 = new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 56)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 60)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(fileEntry.ElementSize * i + 64)) - ); - elements.Add(element); - - _ = 5; - } - - return new Msh02Component() - { - Header = new Msh02Header() - { - BoundingBox = bb, - Center = center, - CenterW = centerW, - Bottom = bottom, - Top = top, - XYRadius = xyRadius - }, - Elements = elements - }; - } - - public class Msh02Component - { - public Msh02Header Header { get; set; } - public List Elements { get; set; } - } - - /// - /// 140 байт в начале файла - /// - public class Msh02Header - { - public BoundingBox BoundingBox { get; set; } - public Vector3 Center { get; set; } - public float CenterW { get; set; } - public Vector3 Bottom { get; set; } - public Vector3 Top { get; set; } - public float XYRadius { get; set; } - } - - public class Msh02Element - { - public ushort StartIndexIn07 { get; set; } - public ushort CountIn07 { get; set; } - public ushort StartOffsetIn0d { get; set; } - public ushort ByteLengthIn0D { get; set; } - public Vector3 LocalMinimum { get; set; } - public Vector3 LocalMaximum { get; set; } - public Vector3 Center { get; set; } - public Vector3 Vector4 { get; set; } - public Vector3 Vector5 { get; set; } - } - - /// - /// 96 bytes - bounding box (8 points each 3 float = 96 bytes) - /// 0x60 bytes or 0x18 by 4 bytes - /// - public class BoundingBox - { - public Vector3 BottomFrontLeft { get; set; } - public Vector3 BottomFrontRight { get; set; } - public Vector3 BottomBackRight { get; set; } - public Vector3 BottomBackLeft { get; set; } - public Vector3 TopBackRight { get; set; } - public Vector3 TopFrontRight { get; set; } - public Vector3 TopBackLeft { get; set; } - public Vector3 TopFrontLeft { get; set; } - } -} \ No newline at end of file diff --git a/ParkanPlayground/Msh07.cs b/ParkanPlayground/Msh07.cs deleted file mode 100644 index 64cbc3e..0000000 --- a/ParkanPlayground/Msh07.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Buffers.Binary; -using NResLib; - -namespace ParkanPlayground; - -public static class Msh07 -{ - public static List ReadComponent( - FileStream mshFs, NResArchive archive) - { - var entry = archive.Files.FirstOrDefault(x => x.FileType == "07 00 00 00"); - - if (entry is null) - { - throw new Exception("Archive doesn't contain file (07)"); - } - - var data = new byte[entry.ElementCount * entry.ElementSize]; - mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); - mshFs.ReadExactly(data, 0, data.Length); - - var elementBytes = data.Chunk(16); - - var elements = elementBytes.Select(x => new Msh07Element() - { - Flags = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0)), - Magic02 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(2)), - Magic04 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(4)), - Magic06 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(6)), - OffsetX = BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(8)), - OffsetY = BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(10)), - OffsetZ = BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(12)), - Magic14 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(14)), - }).ToList(); - - return elements; - } - - public class Msh07Element - { - public ushort Flags { get; set; } - public ushort Magic02 { get; set; } - public ushort Magic04 { get; set; } - public ushort Magic06 { get; set; } - // normalized vector X, need to divide by 32767 to get float in range -1..1 - public short OffsetX { get; set; } - // normalized vector Y, need to divide by 32767 to get float in range -1..1 - public short OffsetY { get; set; } - // normalized vector Z, need to divide by 32767 to get float in range -1..1 - public short OffsetZ { get; set; } - public ushort Magic14 { get; set; } - } -} \ No newline at end of file diff --git a/ParkanPlayground/Msh0D.cs b/ParkanPlayground/Msh0D.cs deleted file mode 100644 index ae7e7d0..0000000 --- a/ParkanPlayground/Msh0D.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Buffers.Binary; -using NResLib; - -namespace ParkanPlayground; - -public static class Msh0D -{ - public const int ElementSize = 20; - - public static List ReadComponent( - FileStream mshFs, NResArchive archive) - { - var entry = archive.Files.FirstOrDefault(x => x.FileType == "0D 00 00 00"); - - if (entry is null) - { - throw new Exception("Archive doesn't contain file (0D)"); - } - - var data = new byte[entry.ElementCount * entry.ElementSize]; - mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); - mshFs.ReadExactly(data, 0, data.Length); - - var elementBytes = data.Chunk(ElementSize); - - var elements = elementBytes.Select(x => new Msh0DElement() - { - Flags = BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0)), - TriangleCount = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(4)), - Magic06 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(6)), - CountOf06 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(8)), - IndexInto06 = BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0xA)), - CountOf03 = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0xE)), - IndexInto03 = BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x10)), - }).ToList(); - - return elements; - } - - public class Msh0DElement - { - public uint Flags { get; set; } - - // Magic04 и Magic06 обрабатываются вместе - - public ushort TriangleCount { get; set; } - public ushort Magic06 { get; set; } - public ushort CountOf06 { get; set; } - public int IndexInto06 { get; set; } - public ushort CountOf03 { get; set; } - public int IndexInto03 { get; set; } - } -} \ No newline at end of file diff --git a/ParkanPlayground/Msh0x01.cs b/ParkanPlayground/Msh0x01.cs new file mode 100644 index 0000000..6d3c91e --- /dev/null +++ b/ParkanPlayground/Msh0x01.cs @@ -0,0 +1,89 @@ +using System.Buffers.Binary; +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x01: таблица узлов модели. +/// +public static class Msh0x01 +{ + public static Msh0x01Component ReadComponent(FileStream mshFs, NResArchive archive) + { + var entry = archive.Files.FirstOrDefault(x => x.FileType == "01 00 00 00"); + + if (entry is null) + { + throw new Exception("Archive doesn't contain node table component (0x01)"); + } + + if (entry.ElementSize <= 0) + { + throw new Exception("Node table component (0x01) has invalid element size"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Node table component (0x01) payload size is not divisible by element size"); + } + + var elementCount = entry.FileLength / entry.ElementSize; + var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var dataSpan = data.AsSpan(); + + var elements = new List(elementCount); + for (var i = 0; i < elementCount; i++) + { + var baseOffset = i * entry.ElementSize; + var rawBytes = dataSpan.Slice(baseOffset, entry.ElementSize).ToArray(); + var slots = new ushort[15]; + Array.Fill(slots, ushort.MaxValue); + var slotWords = Math.Min(slots.Length, Math.Max(0, (entry.ElementSize - 8) / 2)); + for (var slotIndex = 0; slotIndex < slotWords; slotIndex++) + { + slots[slotIndex] = + BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 8 + slotIndex * 2)); + } + + elements.Add(new Node( + rawBytes, + BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset)), + BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 2)), + BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 4)), + BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 6)), + slots)); + } + + return new Msh0x01Component(elements); + } + + + /// Результат чтения MSH-компонента 0x01. + /// Узлы компонента 0x01. + public record Msh0x01Component(List Elements); + + /// Узел 0x01. + /// Сырые байты узла (length = attr3). Нужны для copy-through редких вариантов, например attr3 = 24. + /// [0x00..0x02] Заголовочное слово узла + /// [0x02..0x04] Индекс родителя или связанного узла + /// [0x04..0x06] Начало блока в карте анимации Msh0x13 или 0xFFFF + /// [0x06..0x08] Индекс fallback-ключа в пуле ключей Msh0x08 + /// [0x08..0x26] Индексы slot в Msh0x02 по формуле lod * 5 + group + public record Node( + byte[] RawBytes, + ushort Header0, + ushort ParentOrLink, + ushort AnimMapStart, + ushort FallbackKey, + ushort[] SlotIndex) + { + public ushort ResolveSlotIndex(int lod, int group = 0) + { + var index = lod * 5 + group; + return index >= 0 && index < SlotIndex.Length ? SlotIndex[index] : ushort.MaxValue; + } + } +} diff --git a/ParkanPlayground/Msh0x02.cs b/ParkanPlayground/Msh0x02.cs new file mode 100644 index 0000000..17c318f --- /dev/null +++ b/ParkanPlayground/Msh0x02.cs @@ -0,0 +1,203 @@ +using System.Buffers.Binary; +using Common; +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x02: общий заголовок и таблица slot. +/// +public static class Msh0x02 +{ + public static Msh0x02Component ReadComponent(FileStream mshFs, NResArchive archive) + { + var fileEntry = archive.Files.FirstOrDefault(x => x.FileType == "02 00 00 00"); + + if (fileEntry is null) + { + throw new Exception("Archive doesn't contain slots component (0x02)"); + } + + if (fileEntry.FileLength < 0x8c) + { + throw new Exception("Slots component (0x02) is smaller than the 0x8C-byte header"); + } + + if ((fileEntry.FileLength - 0x8c) % 68 != 0) + { + throw new Exception("Slots component (0x02) payload after header is not divisible by 68"); + } + + var data = new byte[fileEntry.FileLength]; + mshFs.Seek(fileEntry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var header = data.AsSpan(0, 0x8c); // заголовок (length = 0x8C) + + var center = new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x60)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x64)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x68)) + ); + var centerW = BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x6c)); + + var bb = new BoundingBox( + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(4)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(8)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(12)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(16)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(20)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(24)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(28)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(32)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(36)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(40)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(44)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(48)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(52)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(56)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(60)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(64)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(68)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(72)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(76)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(80)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(84)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(88)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(92)) + )); + + var bottom = new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(112)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(116)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(120)) + ); + + var top = new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(124)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(128)), + BinaryPrimitives.ReadSingleLittleEndian(header.Slice(132)) + ); + + var xyRadius = BinaryPrimitives.ReadSingleLittleEndian(header.Slice(136)); + + + var elements = new List(); + var skippedHeader = data.AsSpan(0x8c); + var slotCount = skippedHeader.Length / 68; + for (var i = 0; i < slotCount; i++) + { + var baseOffset = 68 * i; + var opaque = new uint[5]; + for (var opaqueIndex = 0; opaqueIndex < opaque.Length; opaqueIndex++) + { + opaque[opaqueIndex] = + BinaryPrimitives.ReadUInt32LittleEndian(skippedHeader.Slice(baseOffset + 48 + opaqueIndex * 4)); + } + + elements.Add(new Slot( + BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0)), + BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 2)), + BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 4)), + BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 6)), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 8)), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 12)), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 16)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 20)), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 24)), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 28)) + ), + new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 32)), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 36)), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 40)) + ), + BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 44)), + opaque)); + + } + + return new Msh0x02Component( + new Msh02Header(bb, center, centerW, bottom, top, xyRadius), + elements); + } + + /// Результат чтения MSH-компонента 0x02. + /// Заголовок 0x02 (length = 0x8C). + /// Slot records после заголовка. + public record class Msh0x02Component(Msh02Header Header, List Elements); + + /// Заголовок 0x02 (length = 0x8C). + /// [0x00..0x60] Bounding box из 8 точек. + /// [0x60..0x6C] Центральная точка. + /// [0x6C..0x70] W-компонента центра. + /// [0x70..0x7C] Нижняя точка. + /// [0x7C..0x88] Верхняя точка. + /// [0x88..0x8C] Радиус в плоскости XY. + public record class Msh02Header( + BoundingBox BoundingBox, + Vector3 Center, + float CenterW, + Vector3 Bottom, + Vector3 Top, + float XYRadius); + + /// Slot 0x02 (length = 0x44). + /// [0x00..0x02] Первый triangle descriptor в Msh0x07. Для terrain-гипотезы может быть диапазоном Msh0x15. + /// [0x02..0x04] Количество triangle descriptor в Msh0x07. Для terrain-гипотезы может быть count для Msh0x15. + /// [0x04..0x06] Первый batch в таблице 0x0D. + /// [0x06..0x08] Количество batch в таблице 0x0D + /// [0x08..0x14] Минимум локального AABB + /// [0x14..0x20] Максимум локального AABB + /// [0x20..0x2C] Центр bounding sphere + /// [0x2C..0x30] Радиус bounding sphere + /// [0x30..0x44] Пять opaque dword + public record class Slot( + ushort TriStart, + ushort TriCount, + ushort BatchStart, + ushort BatchCount, + Vector3 LocalMinimum, + Vector3 LocalMaximum, + Vector3 Center, + float SphereRadius, + uint[] Opaque); + + /// Bounding box заголовка: 8 точек по 3 float (length = 0x60). + /// [0x00..0x0C] Нижняя передняя левая точка. + /// [0x0C..0x18] Нижняя передняя правая точка. + /// [0x18..0x24] Нижняя задняя правая точка. + /// [0x24..0x30] Нижняя задняя левая точка. + /// [0x30..0x3C] Верхняя передняя левая точка. + /// [0x3C..0x48] Верхняя передняя правая точка. + /// [0x48..0x54] Верхняя задняя правая точка. + /// [0x54..0x60] Верхняя задняя левая точка. + public record class BoundingBox( + Vector3 BottomFrontLeft, + Vector3 BottomFrontRight, + Vector3 BottomBackRight, + Vector3 BottomBackLeft, + Vector3 TopFrontLeft, + Vector3 TopFrontRight, + Vector3 TopBackRight, + Vector3 TopBackLeft); +} diff --git a/ParkanPlayground/Msh03.cs b/ParkanPlayground/Msh0x03.cs similarity index 72% rename from ParkanPlayground/Msh03.cs rename to ParkanPlayground/Msh0x03.cs index b7f31e4..312b9b7 100644 --- a/ParkanPlayground/Msh03.cs +++ b/ParkanPlayground/Msh0x03.cs @@ -4,7 +4,10 @@ using NResLib; namespace ParkanPlayground; -public class Msh03 +/// +/// MSH-компонент 0x03: позиции вершин +/// +public class Msh0x03 { public static List ReadComponent(FileStream mshFs, NResArchive mshNres) { @@ -20,7 +23,12 @@ public class Msh03 throw new Exception("Vertices file (03) element size is not 12"); } - var verticesFile = new byte[verticesFileEntry.ElementCount * verticesFileEntry.ElementSize]; + if (verticesFileEntry.FileLength % verticesFileEntry.ElementSize != 0) + { + throw new Exception("Positions component (0x03) payload size is not divisible by element size"); + } + + var verticesFile = new byte[verticesFileEntry.FileLength]; mshFs.Seek(verticesFileEntry.OffsetInFile, SeekOrigin.Begin); mshFs.ReadExactly(verticesFile, 0, verticesFile.Length); @@ -32,4 +40,4 @@ public class Msh03 ).ToList(); return vertices; } -} \ No newline at end of file +} diff --git a/ParkanPlayground/Msh0x04.cs b/ParkanPlayground/Msh0x04.cs new file mode 100644 index 0000000..a27bc56 --- /dev/null +++ b/ParkanPlayground/Msh0x04.cs @@ -0,0 +1,54 @@ +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x04: упакованные нормали вершин. clamp(component / 127.0, -1..1). +/// +public static class Msh0x04 +{ + public static List ReadComponent(FileStream mshFs, NResArchive archive) + { + var entry = archive.Files.FirstOrDefault(x => x.FileType == "04 00 00 00"); + + if (entry is null) + { + throw new Exception("Archive doesn't contain file (04)"); + } + + if (entry.ElementSize != 4) + { + throw new Exception("Packed normals file (04) element size is not 4"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Packed normals component (0x04) payload size is not divisible by element size"); + } + + var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var elements = new List(entry.FileLength / entry.ElementSize); + for (var i = 0; i < entry.FileLength / entry.ElementSize; i++) + { + var offset = i * 4; + elements.Add(new Msh04Normal( + unchecked((sbyte)data[offset + 0]), + unchecked((sbyte)data[offset + 1]), + unchecked((sbyte)data[offset + 2]), + unchecked((sbyte)data[offset + 3]))); + } + + return elements; + } +} + +/// Упакованная нормаль: четыре int8-компоненты (length = 4). +/// [0x00..0x01] X-компонента. +/// [0x01..0x02] Y-компонента. +/// [0x02..0x03] Z-компонента. +/// [0x03..0x04] W-компонента. +public readonly record struct Msh04Normal(sbyte X, sbyte Y, sbyte Z, sbyte W) +; diff --git a/ParkanPlayground/Msh0x05.cs b/ParkanPlayground/Msh0x05.cs new file mode 100644 index 0000000..ba0de8a --- /dev/null +++ b/ParkanPlayground/Msh0x05.cs @@ -0,0 +1,51 @@ +using System.Buffers.Binary; +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x05: упакованные UV0. component / 1024.0. +/// +public static class Msh0x05 +{ + public static List ReadComponent(FileStream mshFs, NResArchive archive) + { + var entry = archive.Files.FirstOrDefault(x => x.FileType == "05 00 00 00"); + + if (entry is null) + { + throw new Exception("Archive doesn't contain file (05)"); + } + + if (entry.ElementSize != 4) + { + throw new Exception("Packed UV file (05) element size is not 4"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Packed UV component (0x05) payload size is not divisible by element size"); + } + + var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var elements = new List(entry.FileLength / entry.ElementSize); + for (var i = 0; i < entry.FileLength / entry.ElementSize; i++) + { + var span = data.AsSpan(i * 4, 4); + elements.Add(new Msh05Uv( + BinaryPrimitives.ReadInt16LittleEndian(span[0..2]), + BinaryPrimitives.ReadInt16LittleEndian(span[2..4]))); + } + + return elements; + } +} + +/// Упакованные UV0: две int16-компоненты (length = 4). +/// [0x00..0x02] U-компонента, uv = U / 1024.0. +/// [0x02..0x04] V-компонента, uv = V / 1024.0. +public readonly record struct Msh05Uv(short U, short V) +; diff --git a/ParkanPlayground/Msh06.cs b/ParkanPlayground/Msh0x06.cs similarity index 50% rename from ParkanPlayground/Msh06.cs rename to ParkanPlayground/Msh0x06.cs index 8d2622c..f856b84 100644 --- a/ParkanPlayground/Msh06.cs +++ b/ParkanPlayground/Msh0x06.cs @@ -3,7 +3,10 @@ using NResLib; namespace ParkanPlayground; -public static class Msh06 +/// +/// MSH-компонент 0x06: индексный буфер +/// +public static class Msh0x06 { public static List ReadComponent( FileStream mshFs, NResArchive archive) @@ -15,12 +18,22 @@ public static class Msh06 throw new Exception("Archive doesn't contain file (06)"); } - var data = new byte[entry.ElementCount * entry.ElementSize]; + if (entry.ElementSize != 2) + { + throw new Exception("Index buffer component (0x06) element size is not 2"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Index buffer component (0x06) payload size is not divisible by element size"); + } + + var data = new byte[entry.FileLength]; mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.ReadExactly(data, 0, data.Length); - var elements = new List((int)entry.ElementCount); - for (var i = 0; i < entry.ElementCount; i++) + var elements = new List(entry.FileLength / entry.ElementSize); + for (var i = 0; i < entry.FileLength / entry.ElementSize; i++) { elements.Add( BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(i * 2)) @@ -29,4 +42,4 @@ public static class Msh06 return elements; } -} \ No newline at end of file +} diff --git a/ParkanPlayground/Msh0x07.cs b/ParkanPlayground/Msh0x07.cs new file mode 100644 index 0000000..94a40b9 --- /dev/null +++ b/ParkanPlayground/Msh0x07.cs @@ -0,0 +1,80 @@ +using System.Buffers.Binary; +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x07: описатели треугольников для коллизии/пикинга +/// +public static class Msh0x07 +{ + public static List ReadComponent( + FileStream mshFs, NResArchive archive) + { + var entry = archive.Files.FirstOrDefault(x => x.FileType == "07 00 00 00"); + + if (entry is null) + { + throw new Exception("Archive doesn't contain file (07)"); + } + + if (entry.ElementSize != 16) + { + throw new Exception("Triangle descriptor component (0x07) element size is not 16"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Triangle descriptor component (0x07) payload size is not divisible by element size"); + } + + var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var elementBytes = data.Chunk(16); + + var elements = elementBytes.Select(x => new TriangleDescriptor( + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(2)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(4)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(6)), + BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(8)), + BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(10)), + BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(12)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(14)))).ToList(); + + return elements; + } + + /// Описатель треугольника 0x07 (length = 0x10). + /// [0x00..0x02] Флаги треугольника + /// [0x02..0x04] Связь/opaque поле 0 + /// [0x04..0x06] Связь/opaque поле 1 + /// [0x06..0x08] Связь/opaque поле 2 + /// [0x08..0x0A] Упакованная X-компонента нормали + /// [0x0A..0x0C] Упакованная Y-компонента нормали + /// [0x0C..0x0E] Упакованная Z-компонента нормали + /// [0x0E..0x10] Три 2-битных селектора; значение 3 трактуется как 0xFFFF + public readonly record struct TriangleDescriptor( + ushort TriFlags, + ushort Link0, + ushort Link1, + ushort Link2, + short NormalX, + short NormalY, + short NormalZ, + ushort SelectorPacked) + { + public ushort GetSelector(int index) + { + if (index is < 0 or > 2) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + var selector = (SelectorPacked >> (index * 2)) & 0b11; + return selector == 3 ? ushort.MaxValue : (ushort)selector; + } + } +} diff --git a/ParkanPlayground/Msh0A.cs b/ParkanPlayground/Msh0x0A.cs similarity index 58% rename from ParkanPlayground/Msh0A.cs rename to ParkanPlayground/Msh0x0A.cs index a5a0979..15b077b 100644 --- a/ParkanPlayground/Msh0A.cs +++ b/ParkanPlayground/Msh0x0A.cs @@ -4,7 +4,11 @@ using NResLib; namespace ParkanPlayground; -public class Msh0A +/// +/// MSH-компонент 0x0A: строки узлов. +/// У FParkan: Res10 / Node strings. Старое локальное имя: ExternalRefs. +/// +public class Msh0x0A { public static List ReadComponent(FileStream mshFs, NResArchive archive) { @@ -23,19 +27,32 @@ public class Msh0A var strings = new List(); while (pos < data.Length) { + if (pos + 4 > data.Length) + { + throw new Exception("Node strings component (0x0A) has truncated length prefix"); + } + var len = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(pos)); + if (len < 0 || pos + 4 + len > data.Length) + { + throw new Exception("Node strings component (0x0A) has invalid string length"); + } + if (len == 0) { - pos += 4; // empty entry, no string attached - strings.Add(""); // add empty string + pos += 4; + strings.Add(""); } else { - // len is not 0, we need to read it var strBytes = data.AsSpan(pos + 4, len); - var str = Encoding.UTF8.GetString(strBytes); + var str = Encoding.ASCII.GetString(strBytes); strings.Add(str); - pos += len + 4 + 1; // skip length prefix and string itself, +1, because it's null-terminated + pos += len + 4; + if (pos < data.Length && data[pos] == 0) + { + pos++; + } } } @@ -46,4 +63,4 @@ public class Msh0A return strings; } -} \ No newline at end of file +} diff --git a/ParkanPlayground/Msh0x0D.cs b/ParkanPlayground/Msh0x0D.cs new file mode 100644 index 0000000..b058cf5 --- /dev/null +++ b/ParkanPlayground/Msh0x0D.cs @@ -0,0 +1,70 @@ +using System.Buffers.Binary; +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x0D: таблица batch. +/// +public static class Msh0x0D +{ + public const int ElementSize = 20; + + public static List ReadComponent( + FileStream mshFs, NResArchive archive) + { + var entry = archive.Files.FirstOrDefault(x => x.FileType == "0D 00 00 00"); + + if (entry is null) + { + throw new Exception("Archive doesn't contain file (0D)"); + } + + if (entry.ElementSize != ElementSize) + { + throw new Exception("Batch table component (0x0D) element size is not 20"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Batch table component (0x0D) payload size is not divisible by element size"); + } + + var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var elementBytes = data.Chunk(ElementSize); + + var elements = elementBytes.Select(x => new Batch( + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(2)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(4)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(6)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(8)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0xA)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0xE)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x10)))).ToList(); + + return elements; + } + + /// Batch 0x0D + /// [0x00..0x02] Флаги batch. У FParkan: batchFlags. + /// [0x02..0x04] Индекс material slot, резолвится через WEAR/MAT0 pipeline. + /// [0x04..0x06] Opaque поле. Старое локальное имя: TriangleCount; не подтверждено. + /// [0x06..0x08] Opaque поле. Сохранять побайтно в writer. + /// [0x08..0x0A] Количество индексов в индексном буфере 0x06. + /// [0x0A..0x0E] Первый индекс в индексном буфере 0x06. + /// [0x0E..0x10] Opaque поле. Старое локальное имя: CountOf03; не подтверждено. + /// [0x10..0x14] Базовая вершина в position stream 0x03. У FParkan: baseVertex. + public readonly record struct Batch( + ushort BatchFlags, + ushort MaterialIndex, + ushort Opaque4, + ushort Opaque6, + ushort IndexCount, + uint IndexStart, + ushort Opaque14, + uint BaseVertex); +} diff --git a/ParkanPlayground/Msh0x15.cs b/ParkanPlayground/Msh0x15.cs new file mode 100644 index 0000000..3e7c265 --- /dev/null +++ b/ParkanPlayground/Msh0x15.cs @@ -0,0 +1,73 @@ +using System.Buffers.Binary; +using NResLib; + +namespace ParkanPlayground; + +/// +/// MSH-компонент 0x15: terrain-таблица треугольников +/// +public static class Msh0x15 +{ + public static List ReadComponent( + FileStream mshFs, NResArchive archive) + { + var entry = archive.Files.FirstOrDefault(x => x.FileType == "15 00 00 00"); + + if (entry is null) + { + throw new Exception("Archive doesn't contain file (15)"); + } + + if (entry.ElementSize != 28) + { + throw new Exception("Terrain triangle component (0x15) element size is not 28"); + } + + if (entry.FileLength % entry.ElementSize != 0) + { + throw new Exception("Terrain triangle component (0x15) payload size is not divisible by element size"); + } + + var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); + mshFs.ReadExactly(data, 0, data.Length); + + var elementBytes = data.Chunk(28); + + var elements = elementBytes.Select(x => new TerrainTriangle( + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(4)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(8)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(10)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(12)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(14)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(18)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(22)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(26)))).ToList(); + + return elements; + } + + /// Terrain-треугольник 0x15 (length = 0x1C) + /// [0x00..0x04] Флаги треугольника для terrain path + /// [0x04..0x08] Данные материала terrain + /// [0x08..0x0A] Индекс первой вершины в position stream Msh0x03 + /// [0x0A..0x0C] Индекс второй вершины в position stream Msh0x03 + /// [0x0C..0x0E] Индекс третьей вершины в position stream Msh0x03 + /// [0x0E..0x12] Opaque поле + /// [0x12..0x16] Opaque поле + /// [0x16..0x1A] Opaque поле + /// [0x1A..0x1C] Opaque поле + public readonly record struct TerrainTriangle( + uint Flags, + + uint MaterialData, + ushort Vertex1Index, + ushort Vertex2Index, + ushort Vertex3Index, + + uint Opaque0E, + uint Opaque12, + uint Opaque16, + ushort Opaque1A); +} diff --git a/ParkanPlayground/Msh15.cs b/ParkanPlayground/Msh15.cs deleted file mode 100644 index 5197c4c..0000000 --- a/ParkanPlayground/Msh15.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Buffers.Binary; -using NResLib; - -namespace ParkanPlayground; - -public static class Msh15 -{ - public static List ReadComponent( - FileStream mshFs, NResArchive archive) - { - var entry = archive.Files.FirstOrDefault(x => x.FileType == "15 00 00 00"); - - if (entry is null) - { - throw new Exception("Archive doesn't contain file (15)"); - } - - var data = new byte[entry.ElementCount * entry.ElementSize]; - mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); - mshFs.ReadExactly(data, 0, data.Length); - - var elementBytes = data.Chunk(28); - - var elements = elementBytes.Select(x => new Msh15Element() - { - Flags = BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0)), - Magic04 = BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(4)), - Vertex1Index = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(8)), - Vertex2Index = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(10)), - Vertex3Index = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(12)), - Magic0E = BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(14)), - Magic12 = BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(18)), - Magic16 = BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(22)), - Magic1A = BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(26)), - - }).ToList(); - - return elements; - } - - public class Msh15Element - { - public uint Flags { get; set; } - - public uint Magic04 { get; set; } - public ushort Vertex1Index { get; set; } - public ushort Vertex2Index { get; set; } - public ushort Vertex3Index { get; set; } - - public uint Magic0E { get; set; } - public uint Magic12 { get; set; } - public uint Magic16 { get; set; } - public ushort Magic1A { get; set; } - } -} \ No newline at end of file diff --git a/ParkanPlayground/MshConverter.cs b/ParkanPlayground/MshConverter.cs index 943aa9f..aa56c14 100644 --- a/ParkanPlayground/MshConverter.cs +++ b/ParkanPlayground/MshConverter.cs @@ -13,9 +13,7 @@ public enum MshType public class MshConverter { - /// - /// Detects mesh type based on which components are present in the archive. - /// + /// Определяет тип MSH по набору hex-компонентов архива. public static MshType DetectMeshType(NResArchive archive) { bool hasComponent06 = archive.Files.Any(f => f.FileType == "06 00 00 00"); @@ -33,13 +31,12 @@ public class MshConverter return MshType.Unknown; } - /// - /// Converts a .msh file to OBJ format, auto-detecting mesh type. - /// - /// Path to the .msh file - /// Output OBJ path (optional, defaults to input name + .obj) - /// LOD level to export (0 = highest detail) - public void Convert(string mshPath, string? outputPath = null, int lodLevel = 0) + /// Конвертирует .msh в OBJ с автоопределением типа меша. + /// Путь к .msh файлу. + /// Путь к OBJ, по умолчанию рядом с исходным файлом. + /// LOD для экспорта. + /// Группа slot внутри LOD. slotIndex[lod * 5 + group]. + public void Convert(string mshPath, string? outputPath = null, int lodLevel = 0, int group = 0) { var mshNresResult = NResParser.ReadFile(mshPath); if (mshNresResult.Archive is null) @@ -61,10 +58,10 @@ public class MshConverter switch (meshType) { case MshType.Model: - ConvertModel(fs, archive, outputPath, lodLevel); + ConvertModel(fs, archive, outputPath, lodLevel, group); break; case MshType.Landscape: - ConvertLandscape(fs, archive, outputPath, lodLevel); + ConvertLandscape(fs, archive, outputPath, lodLevel, group); break; default: Console.WriteLine("ERROR: Unknown mesh type, cannot convert."); @@ -73,17 +70,17 @@ public class MshConverter } /// - /// Converts a model mesh (robots, buildings, etc.) to OBJ. - /// Uses indexed triangles: 01 → 02 → 0D → 06 → 03 + /// Конвертирует обычную модель в OBJ. + /// Путь данных: 0x01 -> 0x02 -> 0x0D -> 0x06 -> 0x03. /// - private void ConvertModel(FileStream fs, NResArchive archive, string outputPath, int lodLevel) + private void ConvertModel(FileStream fs, NResArchive archive, string outputPath, int lodLevel, int group) { - var component01 = Msh01.ReadComponent(fs, archive); - var component02 = Msh02.ReadComponent(fs, archive); - var component03 = Msh03.ReadComponent(fs, archive); - var component06 = Msh06.ReadComponent(fs, archive); - var component07 = Msh07.ReadComponent(fs, archive); - var component0D = Msh0D.ReadComponent(fs, archive); + var component01 = Msh0x01.ReadComponent(fs, archive); + var component02 = Msh0x02.ReadComponent(fs, archive); + var component03 = Msh0x03.ReadComponent(fs, archive); + var component06 = Msh0x06.ReadComponent(fs, archive); + var component07 = Msh0x07.ReadComponent(fs, archive); + var component0D = Msh0x0D.ReadComponent(fs, archive); Console.WriteLine($"Vertices: {component03.Count}"); Console.WriteLine($"Pieces: {component01.Elements.Count}"); @@ -93,9 +90,10 @@ public class MshConverter sw.WriteLine($"# Model mesh converted from {Path.GetFileName(outputPath)}"); sw.WriteLine($"# LOD level: {lodLevel}"); - // Write all vertices foreach (var v in component03) - sw.WriteLine($"v {v.X:F6} {v.Y:F6} {v.Z:F6}"); + { + sw.WriteLine(FormattableString.Invariant($"v {v.X:F6} {v.Y:F6} {v.Z:F6}")); + } int exportedFaces = 0; @@ -103,32 +101,49 @@ public class MshConverter { var piece = component01.Elements[pieceIndex]; - // Get submesh index for requested LOD - if (lodLevel >= piece.Lod.Length) - continue; - - var submeshIdx = piece.Lod[lodLevel]; + var submeshIdx = piece.ResolveSlotIndex(lodLevel, group); if (submeshIdx == 0xFFFF || submeshIdx >= component02.Elements.Count) continue; sw.WriteLine($"g piece_{pieceIndex}"); var submesh = component02.Elements[submeshIdx]; - var batchStart = submesh.StartOffsetIn0d; - var batchCount = submesh.ByteLengthIn0D; + var batchStart = submesh.BatchStart; + var batchCount = submesh.BatchCount; + if (batchStart + batchCount > component0D.Count) + { + Console.WriteLine($"WARNING: Batch range {batchStart}:{batchCount} out of range for piece {pieceIndex}"); + continue; + } for (var batchIdx = 0; batchIdx < batchCount; batchIdx++) { var batch = component0D[batchStart + batchIdx]; - var baseVertex = batch.IndexInto03; - var indexStart = batch.IndexInto06; - var indexCount = batch.CountOf06; + var baseVertex = (int)batch.BaseVertex; + var indexStart = (int)batch.IndexStart; + var indexCount = batch.IndexCount; + if (indexStart + indexCount > component06.Count) + { + Console.WriteLine($"WARNING: Index range {indexStart}:{indexCount} out of range for piece {pieceIndex}"); + continue; + } for (int i = 0; i < indexCount; i += 3) { + if (i + 2 >= indexCount) + { + Console.WriteLine($"WARNING: Batch has non-triangle index tail in piece {pieceIndex}"); + break; + } + var i1 = baseVertex + component06[indexStart + i]; var i2 = baseVertex + component06[indexStart + i + 1]; var i3 = baseVertex + component06[indexStart + i + 2]; + if (i1 >= component03.Count || i2 >= component03.Count || i3 >= component03.Count) + { + Console.WriteLine($"WARNING: Vertex index out of range in piece {pieceIndex}"); + continue; + } sw.WriteLine($"f {i1 + 1} {i2 + 1} {i3 + 1}"); exportedFaces++; @@ -141,15 +156,15 @@ public class MshConverter } /// - /// Converts a landscape mesh (terrain) to OBJ. - /// Uses direct triangles: 01 → 02 → 15 (via StartIndexIn07/CountIn07) + /// Конвертирует terrain mesh в OBJ. + /// Путь данных является terrain-гипотезой проекта: 0x01 -> 0x02 -> 0x15. /// - private void ConvertLandscape(FileStream fs, NResArchive archive, string outputPath, int lodLevel) + private void ConvertLandscape(FileStream fs, NResArchive archive, string outputPath, int lodLevel, int group) { - var component01 = Msh01.ReadComponent(fs, archive); - var component02 = Msh02.ReadComponent(fs, archive); - var component03 = Msh03.ReadComponent(fs, archive); - var component15 = Msh15.ReadComponent(fs, archive); + var component01 = Msh0x01.ReadComponent(fs, archive); + var component02 = Msh0x02.ReadComponent(fs, archive); + var component03 = Msh0x03.ReadComponent(fs, archive); + var component15 = Msh0x15.ReadComponent(fs, archive); Console.WriteLine($"Vertices: {component03.Count}"); Console.WriteLine($"Triangles: {component15.Count}"); @@ -161,9 +176,10 @@ public class MshConverter sw.WriteLine($"# LOD level: {lodLevel}"); sw.WriteLine($"# Tile grid: {(int)Math.Sqrt(component01.Elements.Count)}x{(int)Math.Sqrt(component01.Elements.Count)}"); - // Write all vertices foreach (var v in component03) - sw.WriteLine($"v {v.X:F6} {v.Y:F6} {v.Z:F6}"); + { + sw.WriteLine(FormattableString.Invariant($"v {v.X:F6} {v.Y:F6} {v.Z:F6}")); + } int exportedFaces = 0; @@ -171,11 +187,7 @@ public class MshConverter { var tile = component01.Elements[tileIdx]; - // Get submesh index for requested LOD - if (lodLevel >= tile.Lod.Length) - continue; - - var submeshIdx = tile.Lod[lodLevel]; + var submeshIdx = tile.ResolveSlotIndex(lodLevel, group); if (submeshIdx == 0xFFFF || submeshIdx >= component02.Elements.Count) continue; @@ -183,9 +195,8 @@ public class MshConverter var submesh = component02.Elements[submeshIdx]; - // For landscape, StartIndexIn07 = triangle start index, CountIn07 = triangle count - var triangleStart = submesh.StartIndexIn07; - var triangleCount = submesh.CountIn07; + var triangleStart = submesh.TriStart; + var triangleCount = submesh.TriCount; for (var triOffset = 0; triOffset < triangleCount; triOffset++) { @@ -197,6 +208,12 @@ public class MshConverter } var tri = component15[triIdx]; + if (tri.Vertex1Index >= component03.Count || tri.Vertex2Index >= component03.Count || tri.Vertex3Index >= component03.Count) + { + Console.WriteLine($"WARNING: Vertex index out of range for tile {tileIdx}"); + continue; + } + sw.WriteLine($"f {tri.Vertex1Index + 1} {tri.Vertex2Index + 1} {tri.Vertex3Index + 1}"); exportedFaces++; } @@ -215,21 +232,21 @@ public class MshConverter using (StreamWriter writer = new StreamWriter(filePath)) { - // Write vertices + // Запись вершин. foreach (var p in points) { writer.WriteLine($"v {p.X} {p.Y} {p.Z}"); } - // Write faces (each face defined by 4 vertices, using 1-based indices) + // Запись граней: каждая грань задается четырьмя вершинами, OBJ использует индексацию с 1. int[][] faces = new int[][] { - new int[] { 1, 2, 3, 4 }, // bottom - new int[] { 5, 6, 7, 8 }, // top - new int[] { 1, 2, 6, 5 }, // front - new int[] { 2, 3, 7, 6 }, // right - new int[] { 3, 4, 8, 7 }, // back - new int[] { 4, 1, 5, 8 } // left + new int[] { 1, 2, 3, 4 }, // низ + new int[] { 5, 6, 7, 8 }, // верх + new int[] { 1, 2, 6, 5 }, // перед + new int[] { 2, 3, 7, 6 }, // право + new int[] { 3, 4, 8, 7 }, // зад + new int[] { 4, 1, 5, 8 } // лево }; foreach (var f in faces) @@ -248,7 +265,7 @@ public class MshConverter foreach (var c in centers) { - // Generate 8 vertices for this cube + // Генерация восьми вершин куба. Vector3[] vertices = new Vector3[] { new Vector3(c.X - half, c.Y - half, c.Z - half), @@ -262,24 +279,24 @@ public class MshConverter new Vector3(c.X - half, c.Y + half, c.Z + half) }; - // Write vertices + // Запись вершин. foreach (var v in vertices) { writer.WriteLine($"v {v.X} {v.Y} {v.Z}"); } - // Define faces (1-based indices, counter-clockwise) + // Описание граней: индексация с 1, порядок против часовой стрелки. int[][] faces = new int[][] { - new int[] { 1, 2, 3, 4 }, // bottom - new int[] { 5, 6, 7, 8 }, // top - new int[] { 1, 2, 6, 5 }, // front - new int[] { 2, 3, 7, 6 }, // right - new int[] { 3, 4, 8, 7 }, // back - new int[] { 4, 1, 5, 8 } // left + new int[] { 1, 2, 3, 4 }, // низ + new int[] { 5, 6, 7, 8 }, // верх + new int[] { 1, 2, 6, 5 }, // перед + new int[] { 2, 3, 7, 6 }, // право + new int[] { 3, 4, 8, 7 }, // зад + new int[] { 4, 1, 5, 8 } // лево }; - // Write faces with offset + // Запись граней со смещением индексов. foreach (var f in faces) { writer.WriteLine( @@ -297,18 +314,18 @@ public class MshConverter { writer.WriteLine("# Exported OBJ file"); - // Write vertices + // Запись вершин. foreach (var v in vertices) { writer.WriteLine($"v {v.X:F2} {v.Y:F2} {v.Z:F2}"); } - // Write edges as lines ("l" elements in .obj format) + // Запись ребер как line-элементов OBJ. foreach (var e in edges) { - // OBJ uses 1-based indexing + // OBJ использует индексацию с 1. writer.WriteLine($"l {e.Index1 + 1} {e.Index2 + 1}"); } } } -} \ No newline at end of file +} diff --git a/Program.cs b/Program.cs index 744c51e..83b996f 100644 --- a/Program.cs +++ b/Program.cs @@ -43,7 +43,7 @@ var targetIds = new[] { 72, 88 }; foreach (var targetId in targetIds) { // Material files are stored with their ID in the archive - var matEntry = matLibResult.Archive.Files.FirstOrDefault(f => f.Index == targetId); + var matEntry = matLibResult.Archive.Files.FirstOrDefault(f => f.DirectoryIndex == targetId); if (matEntry == null) { @@ -52,7 +52,8 @@ foreach (var targetId in targetIds) } Console.WriteLine($"=== Material {targetId} ==="); - Console.WriteLine($" Index: {matEntry.Index}"); + Console.WriteLine($" DirectoryIndex: {matEntry.DirectoryIndex}"); + Console.WriteLine($" SortIndex: {matEntry.SortIndex}"); Console.WriteLine($" Name: {matEntry.FileName}"); Console.WriteLine($" ElementCount (Version): {matEntry.ElementCount}"); Console.WriteLine($" ElementSize (Magic1): {matEntry.ElementSize}"); @@ -126,7 +127,7 @@ foreach (var name in weaMatNames) if (found != null) { - Console.WriteLine($" {name,-10} -> Index {found.Index,3}: {found.FileName}"); + Console.WriteLine($" {name,-10} -> DirectoryIndex {found.DirectoryIndex,3}, SortIndex {found.SortIndex,3}: {found.FileName}"); } else { diff --git a/README.md b/README.md index 7f5a3cf..6e92ef8 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ IComponent ** LoadSomething(undefined4, undefined4, undefined4, undefined4) - Тип 01 - заголовок. Он хранит список деталей (submesh) в разных LOD ``` нулевому элементу добавляется флаг 0x1000000 - Содержит 2 ссылки на файлы анимаций (короткие - файл 13, длинные - файл 08) + Содержит 2 ссылки на анимационные subentry: AnimMapStart указывает в файл 0x13, FallbackKey указывает в файл 0x08. Если интерполируется анимация -0.5s короче чем magic1 у файла 13 И у файла есть OffsetIntoFile13 И ushort значение в файле 13 по этому оффсету > IndexInFile08 (это по-моему выполняется всегда) @@ -260,7 +260,7 @@ IComponent ** LoadSomething(undefined4, undefined4, undefined4, undefined4) Если ни то и ни другое, тогда t = (time - souce.time) / (dest.time - source.time) ``` - Тип 12 - microtexture mapping -- Тип 13 - короткие меш-анимации (почему я это не дописал?) +- Тип 13 - animation map / карта кадров для выбора ключей из файла 08 ``` Буквально (hex) 00 01 01 02 ... @@ -317,7 +317,7 @@ All lightmaps named whatever.0 → they all end up with no DirectDraw palette at По сути представляет собой последовательный список саб-эффектов идущих друг за другом. -Всего существует 9 (1..9) видов эффектов: !описать по мере реверса +Всего существует 10 (1..10) видов FXID-команд. Field-level смысл payload ещё нужно дополнять по мере реверса. Выглядит так, словно весь файл это тоже эффект сам по себе. @@ -500,4 +500,4 @@ enum EPlacementType ## Контакты -Вы можете связаться со мной в [Telegram](https://t.me/bird_egop). \ No newline at end of file +Вы можете связаться со мной в [Telegram](https://t.me/bird_egop). diff --git a/ScrLib/ScrFile.cs b/ScrLib/ScrFile.cs index 11997e9..1d1ff70 100644 --- a/ScrLib/ScrFile.cs +++ b/ScrLib/ScrFile.cs @@ -1,48 +1,36 @@ namespace ScrLib; -public class ScrFile -{ - /// - /// тут всегда число 59 (0x3b) - это число известных игре скриптов - /// - public int Magic { get; set; } +/// SCR файл. +/// Число известных игре скриптов; обычно 59 (0x3B). +/// Количество ScrEntry. +/// Записи SCR. +public record ScrFile(int Magic, int EntryCount, List Entries); - public int EntryCount { get; set; } +/// Запись SCR. +/// Название записи. +/// Индекс записи. +/// Количество вложенных записей. +/// Вложенные записи. +public record ScrEntry(string Title, int Index, int InnerCount, List Inners); - public List Entries { get; set; } -} - -public class ScrEntry -{ - public string Title { get; set; } - - public int Index { get; set; } - - public int InnerCount { get; set; } - - public List Inners { get; set; } -} - -public class ScrEntryInner -{ - /// - /// Номер скрипта в игре (это тех, которых 0x3b) - /// - public int ScriptIndex { get; set; } - - public int UnkInner2 { get; set; } - public int UnkInner3 { get; set; } - - public ScrEntryInnerType Type { get; set; } - - public int UnkInner5 { get; set; } - - public int ArgumentsCount { get; set; } - - public List Arguments { get; set; } - - public int UnkInner7 { get; set; } -} +/// Вложенная запись SCR. +/// Номер скрипта в игре из таблицы известных скриптов (обычно 0x3B записей). +/// Неизвестное поле. Для SetVarsetValue это индекс в Varset. +/// Неизвестное поле. Для SetVarsetValue это устанавливаемое значение. +/// Тип вложенной записи. +/// Неизвестное поле. +/// Количество аргументов. +/// Аргументы вложенной записи. +/// Неизвестное поле. +public record ScrEntryInner( + int ScriptIndex, + int UnkInner2, + int UnkInner3, + ScrEntryInnerType Type, + int UnkInner5, + int ArgumentsCount, + List Arguments, + int UnkInner7); public enum ScrEntryInnerType { @@ -57,4 +45,4 @@ public enum ScrEntryInnerType /// В случае 6, игра берёт UnkInner2 (индекс в Varset) и устанавливает ему значение UnkInner3 /// SetVarsetValue = 6, -} \ No newline at end of file +} diff --git a/ScrLib/ScrParser.cs b/ScrLib/ScrParser.cs index 21821f7..e419ad4 100644 --- a/ScrLib/ScrParser.cs +++ b/ScrLib/ScrParser.cs @@ -13,50 +13,50 @@ public class ScrParser public static ScrFile ReadFile(Stream fs) { - var scrFile = new ScrFile(); + var magic = fs.ReadInt32LittleEndian(); + var entryCount = fs.ReadInt32LittleEndian(); + List entries = []; - scrFile.Magic = fs.ReadInt32LittleEndian(); - - scrFile.EntryCount = fs.ReadInt32LittleEndian(); - scrFile.Entries = []; - - for (var i = 0; i < scrFile.EntryCount; i++) + for (var i = 0; i < entryCount; i++) { - var entry = new ScrEntry(); - entry.Title = fs.ReadLengthPrefixedString(); + var title = fs.ReadLengthPrefixedString(); // тут игра дополнительно вычитывает ещё 1 байт, видимо как \0 для char* fs.ReadByte(); - entry.Index = fs.ReadInt32LittleEndian(); - entry.InnerCount = fs.ReadInt32LittleEndian(); - entry.Inners = []; - for (var i1 = 0; i1 < entry.InnerCount; i1++) + var index = fs.ReadInt32LittleEndian(); + var innerCount = fs.ReadInt32LittleEndian(); + List inners = []; + for (var i1 = 0; i1 < innerCount; i1++) { - var entryInner = new ScrEntryInner(); - entryInner.ScriptIndex = fs.ReadInt32LittleEndian(); + var scriptIndex = fs.ReadInt32LittleEndian(); + var unkInner2 = fs.ReadInt32LittleEndian(); + var unkInner3 = fs.ReadInt32LittleEndian(); + var type = (ScrEntryInnerType)fs.ReadInt32LittleEndian(); + var unkInner5 = fs.ReadInt32LittleEndian(); + var argumentsCount = fs.ReadInt32LittleEndian(); + List arguments = []; - entryInner.UnkInner2 = fs.ReadInt32LittleEndian(); - entryInner.UnkInner3 = fs.ReadInt32LittleEndian(); - entryInner.Type = (ScrEntryInnerType)fs.ReadInt32LittleEndian(); - entryInner.UnkInner5 = fs.ReadInt32LittleEndian(); - - entryInner.ArgumentsCount = fs.ReadInt32LittleEndian(); - - entryInner.Arguments = []; - - for (var i2 = 0; i2 < entryInner.ArgumentsCount; i2++) + for (var i2 = 0; i2 < argumentsCount; i2++) { - entryInner.Arguments.Add(fs.ReadInt32LittleEndian()); + arguments.Add(fs.ReadInt32LittleEndian()); } - entryInner.UnkInner7 = fs.ReadInt32LittleEndian(); - entry.Inners.Add(entryInner); + var unkInner7 = fs.ReadInt32LittleEndian(); + inners.Add(new ScrEntryInner( + scriptIndex, + unkInner2, + unkInner3, + type, + unkInner5, + argumentsCount, + arguments, + unkInner7)); } - scrFile.Entries.Add(entry); + entries.Add(new ScrEntry(title, index, innerCount, inners)); } - return scrFile; + return new ScrFile(magic, entryCount, entries); } -} \ No newline at end of file +} diff --git a/TexmLib/Extensions.cs b/TexmLib/Extensions.cs index 3584a0c..d803eb8 100644 --- a/TexmLib/Extensions.cs +++ b/TexmLib/Extensions.cs @@ -6,11 +6,14 @@ public static class Extensions { return format switch { - 0x22B8 => 32, - 0x115C => 16, + 0x0 => 8, 0x235 => 16, + 0x22C => 16, + 0x115C => 16, + 0x58 => 16, 0x378 => 32, - 0 => 32 + 0x22B8 => 32, + _ => throw new InvalidOperationException($"Unsupported Texm format {format}") }; } -} \ No newline at end of file +} diff --git a/TexmLib/TexmFile.cs b/TexmLib/TexmFile.cs index 2b960f1..b83fea2 100644 --- a/TexmLib/TexmFile.cs +++ b/TexmLib/TexmFile.cs @@ -35,44 +35,31 @@ public record TexmHeader( /// Элементы public record PageHeader(string Page, int Count, List Items); +/// Элемент PAGE-секции TEXM. +/// X-координата в атласе. +/// Ширина элемента в атласе. +/// Y-координата в атласе. +/// Высота элемента в атласе. public record PageItem(short X, short Width, short Y, short Height); -public class TexmFile +/// TEXM файл. +/// Исходное имя файла текстуры TEXM. +/// Заголовок файла (length = 32). +/// Байты mipmap уровней. +/// PAGE-секция атласа, если присутствует. +/// Признак indexed texture с lookup таблицей на 1024 байта. +/// Lookup таблица цветов: 256 цветов * 4 байта. +public record class TexmFile( + string FileName, + TexmHeader Header, + List MipmapBytes, + PageHeader? Pages, + bool IsIndexed, + byte[] LookupColors) { - /// - /// Исходное имя файла текстуры TEXM - /// - public string FileName { get; set; } - - /// - /// Заголовок файла, всегда 32 байта - /// - public TexmHeader Header { get; set; } - - /// - /// Если в одной текстуре есть несколько MipMap уровней, тут будет несколько отдельных текстур - /// - public List MipmapBytes { get; set; } - - /// - /// Если текстура - это атлас, то здесь будет информация о координатах в атласе - /// - public PageHeader? Pages { get; set; } - - /// - /// В некоторых случаях, текстура может быть закодирована как lookup таблица на 1024 байта (256 цветов), - /// тогда сначала идёт 1024 байта lookup таблицы, а далее сами мипмапы, по 1 байту (каждый байт - индекс в lookup таблице) - /// - public bool IsIndexed { get; set; } - - /// - /// Lookup таблица цветов (каждый цвет закодирован как 4 байта (ARGB)) - /// - public byte[] LookupColors { get; set; } - - public async Task WriteToFolder(string folder) + public Task WriteToFolder(string folder) { - if (Directory.Exists(folder)) + if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); } @@ -84,8 +71,8 @@ public class TexmFile { for (var i = 0; i < Header.MipmapCount; i++) { - var mipWidth = Header.Width / (int) Math.Pow(2, i); - var mipHeight = Header.Height / (int) Math.Pow(2, i); + var mipWidth = Math.Max(1, Header.Width >> i); + var mipHeight = Math.Max(1, Header.Height >> i); var reinterpretedPixels = ReinterpretIndexedMipmap(MipmapBytes[i], LookupColors); var image = Image.LoadPixelData(reinterpretedPixels, mipWidth, mipHeight); @@ -93,13 +80,13 @@ public class TexmFile image.SaveAsPng(Path.Combine(outputDir, Path.GetFileName(FileName)) + $"_{mipWidth}x{mipHeight}_indexed.png"); } - return; + return Task.CompletedTask; } for (var i = 0; i < Header.MipmapCount; i++) { - var mipWidth = Header.Width / (int) Math.Pow(2, i); - var mipHeight = Header.Height / (int) Math.Pow(2, i); + var mipWidth = Math.Max(1, Header.Width >> i); + var mipHeight = Math.Max(1, Header.Height >> i); var reinterpretedPixels = ReinterpretMipmapBytesAsRgba32( MipmapBytes[i], @@ -112,14 +99,16 @@ public class TexmFile image.SaveAsPng(Path.Combine(outputDir, Path.GetFileName(FileName)) + $"_{Header.Format}_{mipWidth}x{mipHeight}.png"); } + + return Task.CompletedTask; } public byte[] GetRgba32BytesFromMipmap(int index, out int mipWidth, out int mipHeight) { var mipmapBytes = MipmapBytes[index]; - mipWidth = Header.Width / (int) Math.Pow(2, index); - mipHeight = Header.Height / (int) Math.Pow(2, index); + mipWidth = Math.Max(1, Header.Width >> index); + mipHeight = Math.Max(1, Header.Height >> index); if (IsIndexed) { @@ -151,7 +140,7 @@ public class TexmFile result[i * 4 + 0] = r; result[i * 4 + 1] = g; result[i * 4 + 2] = b; - result[i * 4 + 3] = 255; + result[i * 4 + 3] = a; } return result; @@ -165,6 +154,8 @@ public class TexmFile 888 => ReinterpretAs888(bytes, mipWidth, mipHeight), 4444 => ReinterpretAs4444(bytes, mipWidth, mipHeight), 565 => ReinterpretAs565(bytes, mipWidth, mipHeight), + 556 => ReinterpretAs556(bytes, mipWidth, mipHeight), + 88 => ReinterpretAs88(bytes, mipWidth, mipHeight), _ => throw new InvalidOperationException($"Invalid format {format}") }; @@ -178,22 +169,38 @@ public class TexmFile var result = new byte[bytes.Length * 2]; for (var i = 0; i < span.Length; i += 2) { - var rawPixel = span.Slice(i, 2); + var rawPixel = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(i, 2)); - // swap endianess - (rawPixel[0], rawPixel[1]) = (rawPixel[1], rawPixel[0]); + var r = (byte)(((rawPixel >> 11) & 0b11111) * 255 / 31); + var g = (byte)(((rawPixel >> 5) & 0b111111) * 255 / 63); + var b = (byte)((rawPixel & 0b11111) * 255 / 31); - var r = (byte)(((rawPixel[0] >> 3) & 0b11111) * 255 / 31); - var g = (byte)((((rawPixel[0] & 0b111) << 3) | ((rawPixel[1] >> 5) & 0b111)) * 255 / 63); - var b = (byte)((rawPixel[1] & 0b11111) * 255 / 31); - - result[i / 2 * 4 + 0] = (byte)(0xff - r); - result[i / 2 * 4 + 1] = (byte)(0xff - g); - result[i / 2 * 4 + 2] = (byte)(0xff - b); + result[i / 2 * 4 + 0] = r; + result[i / 2 * 4 + 1] = g; + result[i / 2 * 4 + 2] = b; result[i / 2 * 4 + 3] = 0xff; + } - // swap endianess back - (rawPixel[0], rawPixel[1]) = (rawPixel[1], rawPixel[0]); + return result; + } + + private byte[] ReinterpretAs556(byte[] bytes, int mipWidth, int mipHeight) + { + var span = bytes.AsSpan(); + + var result = new byte[bytes.Length * 2]; + for (var i = 0; i < span.Length; i += 2) + { + var rawPixel = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(i, 2)); + + var r = (byte)(((rawPixel >> 11) & 0b11111) * 255 / 31); + var g = (byte)(((rawPixel >> 6) & 0b11111) * 255 / 31); + var b = (byte)((rawPixel & 0b111111) * 255 / 63); + + result[i / 2 * 4 + 0] = r; + result[i / 2 * 4 + 1] = g; + result[i / 2 * 4 + 2] = b; + result[i / 2 * 4 + 3] = 0xff; } return result; @@ -206,23 +213,36 @@ public class TexmFile var result = new byte[bytes.Length * 2]; for (var i = 0; i < span.Length; i += 2) { - var rawPixel = span.Slice(i, 2); + var rawPixel = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(i, 2)); - // swap endianess - (rawPixel[0], rawPixel[1]) = (rawPixel[1], rawPixel[0]); - - var a = (byte)(((rawPixel[0] >> 4) & 0b1111) * 17); - var b = (byte)(((rawPixel[0] >> 0) & 0b1111) * 17); - var g = (byte)(((rawPixel[1] >> 4) & 0b1111) * 17); - var r = (byte)(((rawPixel[1] >> 0) & 0b1111) * 17); + var a = (byte)(((rawPixel >> 12) & 0b1111) * 17); + var r = (byte)(((rawPixel >> 8) & 0b1111) * 17); + var g = (byte)(((rawPixel >> 4) & 0b1111) * 17); + var b = (byte)((rawPixel & 0b1111) * 17); result[i / 2 * 4 + 0] = r; result[i / 2 * 4 + 1] = g; result[i / 2 * 4 + 2] = b; result[i / 2 * 4 + 3] = a; - - // swap endianess back - (rawPixel[0], rawPixel[1]) = (rawPixel[1], rawPixel[0]); + } + + return result; + } + + private byte[] ReinterpretAs88(byte[] bytes, int mipWidth, int mipHeight) + { + var span = bytes.AsSpan(); + + var result = new byte[bytes.Length * 2]; + for (var i = 0; i < span.Length; i += 2) + { + var l = span[i]; + var a = span[i + 1]; + + result[i / 2 * 4 + 0] = l; + result[i / 2 * 4 + 1] = l; + result[i / 2 * 4 + 2] = l; + result[i / 2 * 4 + 3] = a; } return result; @@ -237,14 +257,9 @@ public class TexmFile { var rawPixel = span.Slice(i, 4); - var r = rawPixel[0]; - var g = rawPixel[1]; - var b = rawPixel[2]; - var w = rawPixel[3]; - - result[i + 0] = r; - result[i + 1] = g; - result[i + 2] = b; + result[i + 0] = rawPixel[0]; + result[i + 1] = rawPixel[1]; + result[i + 2] = rawPixel[2]; result[i + 3] = 255; } @@ -270,7 +285,7 @@ public class TexmFile result[i + 0] = r; result[i + 1] = g; result[i + 2] = b; - result[i + 3] = a; + result[i + 3] = 255; // swap endianess back // (rawPixel[0], rawPixel[1], rawPixel[2], rawPixel[3]) = (rawPixel[3], rawPixel[2], rawPixel[1], rawPixel[0]); @@ -278,4 +293,4 @@ public class TexmFile return result; } -} \ No newline at end of file +} diff --git a/TexmLib/TexmParser.cs b/TexmLib/TexmParser.cs index 5fcf665..0964cb3 100644 --- a/TexmLib/TexmParser.cs +++ b/TexmLib/TexmParser.cs @@ -20,18 +20,18 @@ public class TexmParser var widthBytes = headerBytes[4..8]; var heightBytes = headerBytes[8..12]; var mipmapCountBytes = headerBytes[12..16]; - var strideBytes = headerBytes[16..20]; - var magic1Bytes = headerBytes[20..24]; - var formatOptionFlagsBytes = headerBytes[24..28]; + var flags4Bytes = headerBytes[16..20]; + var flags5Bytes = headerBytes[20..24]; + var unk6Bytes = headerBytes[24..28]; var formatBytes = headerBytes[28..32]; var texmAscii = Encoding.ASCII.GetString(texmHeader).Trim('\0'); var width = BinaryPrimitives.ReadInt32LittleEndian(widthBytes); var height = BinaryPrimitives.ReadInt32LittleEndian(heightBytes); var mipmapCount = BinaryPrimitives.ReadInt32LittleEndian(mipmapCountBytes); - var stride = BinaryPrimitives.ReadInt32LittleEndian(strideBytes); - var magic1 = BinaryPrimitives.ReadInt32LittleEndian(magic1Bytes); - var formatOptionFlags = BinaryPrimitives.ReadInt32LittleEndian(formatOptionFlagsBytes); + var flags4 = BinaryPrimitives.ReadInt32LittleEndian(flags4Bytes); + var flags5 = BinaryPrimitives.ReadInt32LittleEndian(flags5Bytes); + var unk6 = BinaryPrimitives.ReadInt32LittleEndian(unk6Bytes); var format = BinaryPrimitives.ReadInt32LittleEndian(formatBytes); if (texmAscii != "Texm") @@ -39,64 +39,56 @@ public class TexmParser return new TexmParseResult(null, "Файл не начинается с Texm"); } - var textureFile = new TexmFile() - { - FileName = file - }; - var header = new TexmHeader( texmAscii, width, height, mipmapCount, - stride, - magic1, - formatOptionFlags, + flags4, + flags5, + unk6, format ); - textureFile.Header = header; + List mipmapBytesList; + var isIndexed = false; + byte[] lookupColors = []; if (format == 0) { // если формат 0, то текстура использует lookup таблицу в первых 1024 байтах (256 разных цветов в формате ARGB 888) - var lookupColors = new byte[1024]; + lookupColors = new byte[1024]; stream.ReadExactly(lookupColors, 0, lookupColors.Length); - textureFile.LookupColors = lookupColors; - - var mipmapBytesList = ReadMipmapsAsIndexes( + mipmapBytesList = ReadMipmapsAsIndexes( stream, mipmapCount, width, height ); - textureFile.MipmapBytes = mipmapBytesList; - textureFile.IsIndexed = true; + isIndexed = true; } else { - var mipmapBytesList = ReadMipmaps( + mipmapBytesList = ReadMipmaps( stream, format.AsStride(), mipmapCount, width, height ); - - textureFile.MipmapBytes = mipmapBytesList; } + PageHeader? pages = null; if (stream.Position < stream.Length) { // has PAGE data - var pageHeader = ReadPage(stream); - - textureFile.Pages = pageHeader; + pages = ReadPage(stream); } + var textureFile = new TexmFile(file, header, mipmapBytesList, pages, isIndexed, lookupColors); return new TexmParseResult(textureFile); } @@ -142,17 +134,12 @@ public class TexmParser private static List ReadMipmaps(Stream stream, int stride, int mipmapCount, int topWidth, int topHeight) { - if (stride == 0) - { - stride = 16; - } - List mipmapByteLengths = []; for (int i = 0; i < mipmapCount; i++) { - var mipWidth = topWidth / (int) Math.Pow(2, i); - var mipHeight = topHeight / (int) Math.Pow(2, i); + var mipWidth = Math.Max(1, topWidth >> i); + var mipHeight = Math.Max(1, topHeight >> i); var imageByteLength = mipWidth * mipHeight * (stride / 8); mipmapByteLengths.Add(imageByteLength); @@ -178,8 +165,8 @@ public class TexmParser for (int i = 0; i < mipmapCount; i++) { - var mipWidth = topWidth / (int) Math.Pow(2, i); - var mipHeight = topHeight / (int) Math.Pow(2, i); + var mipWidth = Math.Max(1, topWidth >> i); + var mipHeight = Math.Max(1, topHeight >> i); var imageByteLength = mipWidth * mipHeight; mipmapByteLengths.Add(imageByteLength); @@ -198,4 +185,4 @@ public class TexmParser return mipmapBytesList; } -} \ No newline at end of file +}