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
+}