From 5fc8ba53b2cc9e40cca01b0507fcd69801fc4d22 Mon Sep 17 00:00:00 2001 From: bird_egop Date: Tue, 12 May 2026 01:19:19 +0300 Subject: [PATCH] msh improvements --- ParkanPlayground/Msh0x01.cs | 158 ++++++++--- ParkanPlayground/Msh0x02.cs | 267 ++++++++---------- ParkanPlayground/Msh0x06.cs | 74 ++++- ParkanPlayground/Msh0x07.cs | 99 ++++--- ParkanPlayground/Msh0x0D.cs | 118 +++++--- ParkanPlayground/MshConverter.cs | 463 +++++++++++++++---------------- README.md | 4 +- 7 files changed, 674 insertions(+), 509 deletions(-) diff --git a/ParkanPlayground/Msh0x01.cs b/ParkanPlayground/Msh0x01.cs index db43e0c..94f1c66 100644 --- a/ParkanPlayground/Msh0x01.cs +++ b/ParkanPlayground/Msh0x01.cs @@ -5,10 +5,16 @@ namespace ParkanPlayground; /// /// MSH-компонент 0x01: таблица узлов модели. +/// Для обычного AniMesh node имеет size 0x26. +/// У ландшафта metadata/magic1 может иметь другой смысл, например grid_x_count. /// -/// У ландшафта magic1 - grid_x_count public static class Msh0x01 { + public const int NormalElementSize = 0x26; + public const int LodCount = 3; + public const int GroupCount = 5; + public const int SlotCount = LodCount * GroupCount; + public static Msh0x01Component ReadComponent(FileStream mshFs, NResArchive archive) { var entry = archive.Files.FirstOrDefault(x => x.FileType == "01 00 00 00"); @@ -28,71 +34,147 @@ public static class Msh0x01 throw new Exception("Node table component (0x01) payload size is not divisible by element size"); } + if (entry.ElementSize < 8) + { + throw new Exception("Node table component (0x01) element size is too small"); + } + 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); + var nodes = 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++) + var elementSpan = dataSpan.Slice(baseOffset, entry.ElementSize); + + var rawBytes = elementSpan.ToArray(); + + var slotIndices = new ushort[SlotCount]; + Array.Fill(slotIndices, ushort.MaxValue); + + var slotWordCount = Math.Min( + slotIndices.Length, + Math.Max(0, (entry.ElementSize - 8) / 2)); + + for (var slotIndex = 0; slotIndex < slotWordCount; slotIndex++) { - slots[slotIndex] = - BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 8 + slotIndex * 2)); + slotIndices[slotIndex] = + BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x08 + slotIndex * 2, 2)); } - elements.Add(new Node( - rawBytes, - (NodeFlags)BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset)), - BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 2)), - BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 4)), - BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 6)), - slots)); + nodes.Add(new Node( + RawBytes: rawBytes, + Flags: (NodeFlags)BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x00, 2)), + ParentIndexOrLink: BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x02, 2)), + AnimMapStart0x13: BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x04, 2)), + FallbackKey0x08: BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x06, 2)), + Msh02SlotIndicesByLodAndGroup: slotIndices)); } - return new Msh0x01Component(elements); + return new Msh0x01Component(entry.ElementSize, nodes); } - /// Результат чтения MSH-компонента 0x01. - /// Узлы компонента 0x01. - public record Msh0x01Component(List Elements); + /// Размер node entry из NRes metadata. + /// Узлы компонента 0x01. + public sealed record Msh0x01Component( + int ElementSize, + List Nodes) + { + public bool IsNormalAniMeshNodeTable => ElementSize == NormalElementSize; + } - /// Узел 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( + /// Узел MSH 0x01. + /// Сырые байты узла. Нужны для copy-through нестандартных вариантов. + /// [0x00..0x02] Флаги узла. + /// [0x02..0x04] Parent node index. 0xFFFF обычно значит root/no parent. + /// [0x04..0x06] Начало блока в MSH 0x13 animation map или 0xFFFF. + /// [0x06..0x08] Fallback key / index в MSH 0x08. + /// + /// [0x08..0x26] Индексы geometry slot в MSH 0x02. + /// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует. + /// + public sealed record Node( byte[] RawBytes, NodeFlags Flags, - ushort ParentOrLink_possibly_0x02_index, - ushort AnimMapStart, - ushort FallbackKey, - ushort[] Msh02SlotIndicesByLodAndGroupSlotIndex) + ushort ParentIndexOrLink, + ushort AnimMapStart0x13, + ushort FallbackKey0x08, + ushort[] Msh02SlotIndicesByLodAndGroup) { + public bool IsRoot => ParentIndexOrLink == ushort.MaxValue; + + public int ParentIndexOrMinusOne => + ParentIndexOrLink == ushort.MaxValue ? -1 : ParentIndexOrLink; + public ushort ResolveSlotIndex(int lod, int group = 0) { - var index = lod * 5 + group; - return index >= 0 && index < Msh02SlotIndicesByLodAndGroupSlotIndex.Length ? Msh02SlotIndicesByLodAndGroupSlotIndex[index] : ushort.MaxValue; + var index = lod * GroupCount + group; + + return index >= 0 && index < Msh02SlotIndicesByLodAndGroup.Length + ? Msh02SlotIndicesByLodAndGroup[index] + : ushort.MaxValue; + } + + public bool HasGeometrySlot(int lod, int group = 0) => + ResolveSlotIndex(lod, group) != ushort.MaxValue; + + public int CountLodsForGroup(int group = 0) + { + var count = 0; + + for (var lod = 0; lod < LodCount; lod++) + { + if (!HasGeometrySlot(lod, group)) + { + break; + } + + count++; + } + + return count; } } } +[Flags] public enum NodeFlags : ushort { - // very uncertain - MSH01_NODE_FLAG_UNKNOWN_SKIP_RECURSE_0x04 = 0x4, - MSH01_NODE_FLAG_STOP_CHILD_TRAVERSAL = 0x10, - MSH01_NODE_FLAG_NO_SHADOW = 0x40 + None = 0, + + /// + /// Still uncertain. In recursive bounds/intersection paths this can suppress/alter child recursion. + /// Seen as child_node.flags & 0x04. + /// + UnknownSkipChild0x04 = 0x0004, + + /// + /// Stops recursive traversal into children for bounds/render/intersection helpers. + /// + StopChildTraversal = 0x0010, + + /// + /// Special render-group-4 mode bit. In render group 4, selects alternate piece render mode. + /// Earlier also seen in special render/clip behavior. + /// + Group4AltRenderMode = 0x0020, + + /// + /// Exclude from shadow / no shadow. CAniMesh tracks has_any_shadow_casting_piece when this bit is absent. + /// + NoShadow = 0x0040, + + /// + /// Used during attached MSH load: if parent/root description contains "central", piece gets hidden/excluded flag. + /// Exact semantic name still provisional. + /// + CheckParentDescriptionCentral = 0x0800, } \ No newline at end of file diff --git a/ParkanPlayground/Msh0x02.cs b/ParkanPlayground/Msh0x02.cs index 3757240..3f4da1d 100644 --- a/ParkanPlayground/Msh0x02.cs +++ b/ParkanPlayground/Msh0x02.cs @@ -5,189 +5,168 @@ using NResLib; namespace ParkanPlayground; /// -/// MSH-компонент 0x02: общий заголовок и таблица slot. +/// MSH-компонент 0x02: общий bounds header и таблица geometry slots. +/// Header size = 0x8C, slot size = 0x44. /// public static class Msh0x02 { + public const int HeaderSize = 0x8C; + public const int SlotSize = 0x44; + public static Msh0x02Component ReadComponent(FileStream mshFs, NResArchive archive) { - var fileEntry = archive.Files.FirstOrDefault(x => x.FileType == "02 00 00 00"); + var entry = archive.Files.FirstOrDefault(x => x.FileType == "02 00 00 00"); - if (fileEntry is null) + if (entry is null) { - throw new Exception("Archive doesn't contain slots component (0x02)"); + throw new Exception("Archive doesn't contain geometry slot component (0x02)"); } - if (fileEntry.FileLength < 0x8c) + if (entry.FileLength < HeaderSize) { - throw new Exception("Slots component (0x02) is smaller than the 0x8C-byte header"); + throw new Exception("Geometry slot component (0x02) is smaller than the 0x8C-byte header"); } - if ((fileEntry.FileLength - 0x8c) % 68 != 0) + if ((entry.FileLength - HeaderSize) % SlotSize != 0) { - throw new Exception("Slots component (0x02) payload after header is not divisible by 68"); + throw new Exception("Geometry slot component (0x02) payload after header is not divisible by 0x44"); } - var data = new byte[fileEntry.FileLength]; - mshFs.Seek(fileEntry.OffsetInFile, SeekOrigin.Begin); + var data = new byte[entry.FileLength]; + + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.ReadExactly(data, 0, data.Length); - var header = data.AsSpan(0, 0x8c); // заголовок (length = 0x8C) + var span = data.AsSpan(); - 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 header = ReadHeader(span.Slice(0, HeaderSize)); - 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 / 0x44; + var slotBytes = span.Slice(HeaderSize); + var slotCount = slotBytes.Length / SlotSize; + var slots = new List(slotCount); for (var i = 0; i < slotCount; i++) { - var baseOffset = 0x44 * i; + var slot = slotBytes.Slice(i * SlotSize, SlotSize); - var opaque = new uint[3]; - for (var opaqueIndex = 0; opaqueIndex < opaque.Length; opaqueIndex++) - { - opaque[opaqueIndex] = - BinaryPrimitives.ReadUInt32LittleEndian( - skippedHeader.Slice(baseOffset + 0x38 + opaqueIndex * 4) - ); - } - - elements.Add(new Slot( - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x00)), - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x02)), - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x04)), - BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x06)), - new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x08)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x0c)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x10)) - ), - new Vector3( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x14)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x18)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x1c)) - ), - new Sphere( - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x20)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x24)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x28)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x2c))), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x30)), - BinaryPrimitives.ReadSingleLittleEndian(skippedHeader.Slice(baseOffset + 0x34)), - opaque - )); + slots.Add(new GeometrySlot( + TriStart0x07: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x00, 2)), + TriCount0x07: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x02, 2)), + BatchStart0x0D: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x04, 2)), + BatchCount0x0D: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x06, 2)), + LocalMinimum: ReadVector3(slot, 0x08), + LocalMaximum: ReadVector3(slot, 0x14), + BoundingSphere: ReadSphere(slot, 0x20), + BaseXyArea: BinaryPrimitives.ReadSingleLittleEndian(slot.Slice(0x30, 4)), + BaseVolume: BinaryPrimitives.ReadSingleLittleEndian(slot.Slice(0x34, 4)), + Opaque38: BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(0x38, 4)), + Opaque3C: BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(0x3C, 4)), + Opaque40: BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(0x40, 4)))); } - return new Msh0x02Component( - new Msh02Header(bb, new Sphere(center.X, center.Y, center.Z, centerW), bottom, top, xyRadius), - elements); + return new Msh0x02Component(header, slots); + } + + private static Msh02Header ReadHeader(ReadOnlySpan header) + { + var bbox = new BoundingBox( + BottomFrontLeft: ReadVector3(header, 0x00), + BottomFrontRight: ReadVector3(header, 0x0C), + BottomBackRight: ReadVector3(header, 0x18), + BottomBackLeft: ReadVector3(header, 0x24), + TopFrontLeft: ReadVector3(header, 0x30), + TopFrontRight: ReadVector3(header, 0x3C), + TopBackRight: ReadVector3(header, 0x48), + TopBackLeft: ReadVector3(header, 0x54)); + + return new Msh02Header( + BoundingBox: bbox, + BoundingSphere: ReadSphere(header, 0x60), + Bottom: ReadVector3(header, 0x70), + Top: ReadVector3(header, 0x7C), + XyRadius: BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x88, 4))); + } + + private static Vector3 ReadVector3(ReadOnlySpan data, int offset) + { + return new Vector3( + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x00, 4)), + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x04, 4)), + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x08, 4))); + } + + private static Sphere ReadSphere(ReadOnlySpan data, int offset) + { + return new Sphere( + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x00, 4)), + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x04, 4)), + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x08, 4)), + BinaryPrimitives.ReadSingleLittleEndian(data.Slice(offset + 0x0C, 4))); } /// Результат чтения MSH-компонента 0x02. - /// Заголовок 0x02 (length = 0x8C). - /// Slot records после заголовка. - public record class Msh0x02Component(Msh02Header Header, List Elements); + /// Header 0x02, length = 0x8C. + /// Geometry slots после header. + public sealed record Msh0x02Component( + Msh02Header Header, + List Slots) + { + /// + /// Backward-compatible alias, если старый код ещё использует Elements. + /// + public List Elements => Slots; + } - /// Заголовок 0x02 (length = 0x8C). + /// Заголовок MSH 0x02, length = 0x8C. /// [0x00..0x60] Bounding box из 8 точек. - /// [0x60..0x70] Bounding sphere. - /// [0x70..0x7C] Нижняя точка. - /// [0x7C..0x88] Верхняя точка. - /// [0x88..0x8C] Радиус в плоскости XY. - public record class Msh02Header( + /// [0x60..0x70] Bounding sphere: xyz = center, w = radius. + /// [0x70..0x7C] Нижняя/опорная точка меша. + /// [0x7C..0x88] Верхняя точка меша. + /// [0x88..0x8C] Радиус/extent в плоскости XY. + public sealed record Msh02Header( BoundingBox BoundingBox, Sphere BoundingSphere, Vector3 Bottom, Vector3 Top, - float XYRadius); + float XyRadius); - /// Geometry 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..0x30] Bounding sphere - /// [0x30..0x34] Базовая XY-площадь / footprint area до масштабирования. - /// [0x34..0x38] Базовый объём до масштабирования. - /// [0x38..0x44] 3 opaque dword - public record Slot( - ushort TriStart, - ushort TriCount, - ushort BatchStart, - ushort BatchCount, + /// Geometry slot MSH 0x02, length = 0x44. + /// [0x00..0x02] Первый triangle descriptor в MSH 0x07. + /// [0x02..0x04] Количество triangle descriptor / triangle range count. + /// [0x04..0x06] Первый batch в MSH 0x0D. + /// [0x06..0x08] Количество batch в MSH 0x0D. + /// [0x08..0x14] Local AABB minimum. + /// [0x14..0x20] Local AABB maximum. + /// [0x20..0x30] Local bounding sphere. + /// [0x30..0x34] Базовая XY-площадь / footprint area до mesh scale. + /// [0x34..0x38] Базовый объём до mesh scale. + /// [0x38..0x3C] Opaque dword. + /// [0x3C..0x40] Opaque dword. + /// [0x40..0x44] Opaque dword. + public readonly record struct GeometrySlot( + ushort TriStart0x07, + ushort TriCount0x07, + ushort BatchStart0x0D, + ushort BatchCount0x0D, Vector3 LocalMinimum, Vector3 LocalMaximum, Sphere BoundingSphere, float BaseXyArea, float BaseVolume, - uint[] Opaque); + uint Opaque38, + uint Opaque3C, + uint Opaque40) + { + public int BatchEndExclusive0x0D => BatchStart0x0D + BatchCount0x0D; - /// Bounding box заголовка: 8 точек по 3 float (length = 0x60). + public int TriEndExclusive0x07 => TriStart0x07 + TriCount0x07; + + public bool HasBatches => BatchCount0x0D != 0; + + public bool HasTriangles => TriCount0x07 != 0; + } + + /// Bounding box заголовка: 8 точек по 3 float, length = 0x60. /// [0x00..0x0C] Нижняя передняя левая точка. /// [0x0C..0x18] Нижняя передняя правая точка. /// [0x18..0x24] Нижняя задняя правая точка. @@ -196,7 +175,7 @@ public static class Msh0x02 /// [0x3C..0x48] Верхняя передняя правая точка. /// [0x48..0x54] Верхняя задняя правая точка. /// [0x54..0x60] Верхняя задняя левая точка. - public record BoundingBox( + public sealed record BoundingBox( Vector3 BottomFrontLeft, Vector3 BottomFrontRight, Vector3 BottomBackRight, @@ -205,4 +184,4 @@ public static class Msh0x02 Vector3 TopFrontRight, Vector3 TopBackRight, Vector3 TopBackLeft); -} +} \ No newline at end of file diff --git a/ParkanPlayground/Msh0x06.cs b/ParkanPlayground/Msh0x06.cs index f856b84..a87c986 100644 --- a/ParkanPlayground/Msh0x06.cs +++ b/ParkanPlayground/Msh0x06.cs @@ -4,42 +4,88 @@ using NResLib; namespace ParkanPlayground; /// -/// MSH-компонент 0x06: индексный буфер +/// MSH-компонент 0x06: индексный буфер. +/// Используется batch-ами 0x0D через Batch.IndexStart / Batch.IndexCount. +/// Индексы являются ushort и обычно читаются тройками как triangle indices. /// public static class Msh0x06 { - public static List ReadComponent( - FileStream mshFs, NResArchive archive) + public const int ElementSize = 2; + + public static List ReadComponent(FileStream mshFs, NResArchive archive) { var entry = archive.Files.FirstOrDefault(x => x.FileType == "06 00 00 00"); if (entry is null) { - throw new Exception("Archive doesn't contain file (06)"); + throw new Exception("Archive doesn't contain index buffer component (0x06)"); } - if (entry.ElementSize != 2) + if (entry.ElementSize != ElementSize) { throw new Exception("Index buffer component (0x06) element size is not 2"); } - if (entry.FileLength % entry.ElementSize != 0) + if (entry.FileLength % ElementSize != 0) { - throw new Exception("Index buffer component (0x06) payload size is not divisible by element size"); + throw new Exception("Index buffer component (0x06) payload size is not divisible by 2"); } 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(); + var indices = new List(entry.FileLength / ElementSize); + + for (var offset = 0; offset < span.Length; offset += ElementSize) { - elements.Add( - BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(i * 2)) - ); + indices.Add(BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, ElementSize))); } - return elements; + return indices; } -} + + /// + /// Возвращает triangle triplets из диапазона индексов. + /// Удобно для обхода batch.IndexStart / batch.IndexCount из 0x0D. + /// + public static IEnumerable EnumerateTriangles( + IReadOnlyList indices, + int indexStart, + int indexCount) + { + if (indexStart < 0) + { + throw new ArgumentOutOfRangeException(nameof(indexStart)); + } + + if (indexCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(indexCount)); + } + + if (indexStart + indexCount > indices.Count) + { + throw new ArgumentException("Index range is outside the 0x06 index buffer"); + } + + for (var i = 0; i + 2 < indexCount; i += 3) + { + yield return new TriangleIndices( + indices[indexStart + i + 0], + indices[indexStart + i + 1], + indices[indexStart + i + 2]); + } + } + + /// Тройка индексов triangle из MSH 0x06. + /// Первый vertex index внутри batch/base vertex range. + /// Второй vertex index внутри batch/base vertex range. + /// Третий vertex index внутри batch/base vertex range. + public readonly record struct TriangleIndices( + ushort A, + ushort B, + ushort C); +} \ No newline at end of file diff --git a/ParkanPlayground/Msh0x07.cs b/ParkanPlayground/Msh0x07.cs index 94a40b9..631c9a1 100644 --- a/ParkanPlayground/Msh0x07.cs +++ b/ParkanPlayground/Msh0x07.cs @@ -1,71 +1,88 @@ using System.Buffers.Binary; +using Common; using NResLib; namespace ParkanPlayground; /// -/// MSH-компонент 0x07: описатели треугольников для коллизии/пикинга +/// MSH-компонент 0x07: triangle descriptors. +/// Используется geometry walker-ами для raycast / point-inside / фильтрации triangle flags. +/// Геометрические vertex indices лежат не здесь, а в MSH 0x06. /// public static class Msh0x07 { - public static List ReadComponent( - FileStream mshFs, NResArchive archive) + public const int ElementSize = 0x10; + public const float PackedNormalScale = 1.0f / 32767.0f; + + 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)"); + throw new Exception("Archive doesn't contain triangle descriptor component (0x07)"); } - if (entry.ElementSize != 16) + if (entry.ElementSize != ElementSize) { throw new Exception("Triangle descriptor component (0x07) element size is not 16"); } - if (entry.FileLength % entry.ElementSize != 0) + if (entry.FileLength % ElementSize != 0) { - throw new Exception("Triangle descriptor component (0x07) payload size is not divisible by element size"); + throw new Exception("Triangle descriptor component (0x07) payload size is not divisible by 16"); } var data = new byte[entry.FileLength]; + mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.ReadExactly(data, 0, data.Length); - var elementBytes = data.Chunk(16); + var span = data.AsSpan(); + var descriptors = new List(entry.FileLength / ElementSize); - 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(); + for (var offset = 0; offset < span.Length; offset += ElementSize) + { + var element = span.Slice(offset, ElementSize); - return elements; + descriptors.Add(new TriangleDescriptor( + Flags: (TriangleFlags)BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x00, 2)), + Link0: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x02, 2)), + Link1: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x04, 2)), + Link2: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x06, 2)), + PackedNormalX: BinaryPrimitives.ReadInt16LittleEndian(element.Slice(0x08, 2)), + PackedNormalY: BinaryPrimitives.ReadInt16LittleEndian(element.Slice(0x0A, 2)), + PackedNormalZ: BinaryPrimitives.ReadInt16LittleEndian(element.Slice(0x0C, 2)), + SelectorPacked: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x0E, 2)))); + } + + return descriptors; } - /// Описатель треугольника 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 + /// Описатель triangle MSH 0x07, length = 0x10. + /// [0x00..0x02] Triangle flags. Используются GeometryWalkFilter require/exclude. + /// [0x02..0x04] Opaque/link поле 0. + /// [0x04..0x06] Opaque/link поле 1. + /// [0x06..0x08] Opaque/link поле 2. + /// [0x08..0x0A] Packed normal X, int16, scale = 1 / 32767. + /// [0x0A..0x0C] Packed normal Y, int16, scale = 1 / 32767. + /// [0x0C..0x0E] Packed normal Z, int16, scale = 1 / 32767. + /// [0x0E..0x10] Packed selectors. Старое наблюдение: 3 трактуется как 0xFFFF. public readonly record struct TriangleDescriptor( - ushort TriFlags, + TriangleFlags Flags, ushort Link0, ushort Link1, ushort Link2, - short NormalX, - short NormalY, - short NormalZ, + short PackedNormalX, + short PackedNormalY, + short PackedNormalZ, ushort SelectorPacked) { + public Vector3 Normal => new( + PackedNormalX * PackedNormalScale, + PackedNormalY * PackedNormalScale, + PackedNormalZ * PackedNormalScale); + public ushort GetSelector(int index) { if (index is < 0 or > 2) @@ -74,7 +91,25 @@ public static class Msh0x07 } var selector = (SelectorPacked >> (index * 2)) & 0b11; - return selector == 3 ? ushort.MaxValue : (ushort)selector; + + return selector == 3 + ? ushort.MaxValue + : (ushort)selector; + } + + public bool MatchesFilter(TriangleFlags required, TriangleFlags excluded) + { + return (Flags & required) == required + && (Flags & excluded) == 0; } } } + +[Flags] +public enum TriangleFlags : ushort +{ + None = 0, + + // Пока не называем отдельные bits, потому что мы видели только require/exclude фильтрацию. + // Добавляй конкретные имена по мере нахождения usage sites. +} \ No newline at end of file diff --git a/ParkanPlayground/Msh0x0D.cs b/ParkanPlayground/Msh0x0D.cs index 730d4f6..2934cb9 100644 --- a/ParkanPlayground/Msh0x0D.cs +++ b/ParkanPlayground/Msh0x0D.cs @@ -4,14 +4,14 @@ using NResLib; namespace ParkanPlayground; /// -/// MSH-компонент 0x0D: таблица batch. +/// MSH-компонент 0x0D: таблица render/intersection batches. +/// Используется через MSH_02_geometry_slot.batch_start_0x0d / batch_count_0x0d. /// public static class Msh0x0D { public const int ElementSize = 20; - - public static List ReadComponent( - FileStream mshFs, NResArchive archive) + + public static List ReadComponent(FileStream mshFs, NResArchive archive) { var entry = archive.Files.FirstOrDefault(x => x.FileType == "0D 00 00 00"); @@ -31,53 +31,93 @@ public static class Msh0x0D } 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( - (BatchFlags)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; + return data + .Chunk(ElementSize) + .Select(x => new Batch( + (BatchFlags)BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x00)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x02)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x04)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x06)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x08)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x0A)), + BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x0E)), + BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x10)))) + .ToList(); } - /// 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. + /// MSH batch 0x0D, sizeof 0x14. + /// [0x00..0x02] Флаги batch. + /// [0x02..0x04] Индекс material slot. Может быть overridden CAniMesh::forced_material_index. + /// [0x04..0x06] Opaque. В GetBatchRenderData попадает в packed field вместе с material/lightmap данными. + /// [0x06..0x08] Локальный batch index. В GetBatchRenderData складывается с MSH_piece.local_index_base / batch base. + /// [0x08..0x0A] Количество индексов из component 0x06. + /// [0x0A..0x0E] Первый индекс в component 0x06. + /// [0x0E..0x10] Количество вершин для render primitive. + /// [0x10..0x14] Base vertex в vertex streams, включая position stream 0x03. public readonly record struct Batch( - BatchFlags BatchFlags, + BatchFlags Flags, ushort MaterialIndex, - ushort Opaque4, - ushort Opaque6, + ushort Opaque04, + ushort LocalBatchIndex, ushort IndexCount, uint IndexStart, - ushort Opaque14, + ushort VertexCount, uint BaseVertex); } +[Flags] public enum BatchFlags : ushort { - MSH0D_BATCH_SPECIAL_SUBMIT = 1, - MSH0D_BATCH_INTERSECT_TWO_SIDED = 2, - MSH0D_BATCH_SPECIAL_PASS_OR_CONDITIONAL = 5, - MSH0D_BATCH_USE_MATERIAL_PHASE = 8, - MSH0D_BATCH_SUPPRESS_BATCH = 32, - MSH0D_BATCH_FORCE_PASS2 = 256, - MSH0D_BATCH_EMULATE_POINT_LIGHTS = 2048, - MSH0D_BATCH_HAS_LIGHTMAP_OR_TEXCOORD1 = 8192, - MSH0D_BATCH_FACING_TEST_EARLY_OUT = 16384, + None = 0, + + /// + /// Special/immediate submit path in CShade::SubmitMeshPieceBatches. + /// + SpecialSubmit = 0x0001, + + /// + /// Intersection path tries both facing directions / disables facing test. + /// Also likely related to two-sided handling. + /// + DisableFacingTest = 0x0002, + + /// + /// Use material phase path instead of normal material animation frame. + /// + UseMaterialPhase = 0x0004, + + /// + /// Special pass / conditional batch logic. + /// In GetBatchRenderData this participates in conditional suppression logic. + /// + SpecialPassOrConditional = 0x0008, + + /// + /// Batch is suppressed when paired with SpecialPassOrConditional. + /// + SuppressBatch = 0x0020, + + /// + /// Forces second/translucent-ish render pass. Exact render-state meaning still provisional. + /// + ForcePass2 = 0x0100, + + /// + /// Enables point-light emulation path. + /// + EmulatePointLights = 0x0800, + + /// + /// Batch has lightmap / secondary texcoord related data. + /// + HasLightmapOrTexcoord1 = 0x2000, + + /// + /// Facing/culling mode bit. Exact render meaning is still provisional. + /// + FacingTestEarlyOut = 0x4000, } \ No newline at end of file diff --git a/ParkanPlayground/MshConverter.cs b/ParkanPlayground/MshConverter.cs index aa56c14..93d7d53 100644 --- a/ParkanPlayground/MshConverter.cs +++ b/ParkanPlayground/MshConverter.cs @@ -7,325 +7,308 @@ namespace ParkanPlayground; public enum MshType { Unknown, - Model, // Has component 06 (indices), 0D (batches), 07 - Landscape // Has component 0B (per-triangle material), uses 15 directly + /// + /// Для обычной модели минимальный геометрический путь сейчас выглядит так: + /// 0x01 node + /// -> 0x02 geometry slot + /// -> 0x0D batch + /// -> 0x06 indices + /// -> 0x03 positions + /// + Model, + Landscape } -public class MshConverter +public sealed class MshConverter { - /// Определяет тип MSH по набору hex-компонентов архива. - public static MshType DetectMeshType(NResArchive archive) + public void Convert(string mshPath, string? outputPath = null, int lod = 0, int group = 0) { - bool hasComponent06 = archive.Files.Any(f => f.FileType == "06 00 00 00"); - bool hasComponent0B = archive.Files.Any(f => f.FileType == "0B 00 00 00"); - bool hasComponent0D = archive.Files.Any(f => f.FileType == "0D 00 00 00"); - - // Model: Uses indexed triangles via component 06 and batches via 0D - if (hasComponent06 && hasComponent0D) - return MshType.Model; - - // Landscape: Uses direct triangles in component 15, with material data in 0B - if (hasComponent0B && !hasComponent06) - return MshType.Landscape; - - return MshType.Unknown; - } - - /// Конвертирует .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) + var result = NResParser.ReadFile(mshPath); + if (result.Archive is null) { - Console.WriteLine($"ERROR: Failed to read NRes archive: {mshNresResult.Error}"); + Console.WriteLine($"ERROR: Failed to read NRes archive: {result.Error}"); return; } - - var archive = mshNresResult.Archive; + + var archive = result.Archive; var meshType = DetectMeshType(archive); - + outputPath ??= Path.ChangeExtension(mshPath, ".obj"); - + Console.WriteLine($"Converting: {Path.GetFileName(mshPath)}"); Console.WriteLine($"Detected type: {meshType}"); - + Console.WriteLine($"LOD: {lod}, group: {group}"); + using var fs = new FileStream(mshPath, FileMode.Open, FileAccess.Read, FileShare.Read); - + switch (meshType) { case MshType.Model: - ConvertModel(fs, archive, outputPath, lodLevel, group); + ConvertModel(fs, archive, outputPath, lod, group); break; + case MshType.Landscape: - ConvertLandscape(fs, archive, outputPath, lodLevel, group); + ConvertLandscape(fs, archive, outputPath, lod, group); break; + default: - Console.WriteLine("ERROR: Unknown mesh type, cannot convert."); + Console.WriteLine("ERROR: Unknown or unsupported MSH type."); break; } } - /// - /// Конвертирует обычную модель в OBJ. - /// Путь данных: 0x01 -> 0x02 -> 0x0D -> 0x06 -> 0x03. - /// - private void ConvertModel(FileStream fs, NResArchive archive, string outputPath, int lodLevel, int group) + public static MshType DetectMeshType(NResArchive 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); + var has03 = HasComponent(archive, "03"); + var has06 = HasComponent(archive, "06"); + var has0D = HasComponent(archive, "0D"); + var has15 = HasComponent(archive, "15"); - Console.WriteLine($"Vertices: {component03.Count}"); - Console.WriteLine($"Pieces: {component01.Elements.Count}"); - Console.WriteLine($"Submeshes: {component02.Elements.Count}"); - - using var sw = new StreamWriter(outputPath, false, new UTF8Encoding(false)); - sw.WriteLine($"# Model mesh converted from {Path.GetFileName(outputPath)}"); - sw.WriteLine($"# LOD level: {lodLevel}"); - - foreach (var v in component03) + if (has03 && has06 && has0D) { - sw.WriteLine(FormattableString.Invariant($"v {v.X:F6} {v.Y:F6} {v.Z:F6}")); + return MshType.Model; } - int exportedFaces = 0; - - for (var pieceIndex = 0; pieceIndex < component01.Elements.Count; pieceIndex++) + if (has03 && has15 && !has06) { - var piece = component01.Elements[pieceIndex]; - - var submeshIdx = piece.ResolveSlotIndex(lodLevel, group); - if (submeshIdx == 0xFFFF || submeshIdx >= component02.Elements.Count) - continue; + return MshType.Landscape; + } - sw.WriteLine($"g piece_{pieceIndex}"); - - var submesh = component02.Elements[submeshIdx]; - var batchStart = submesh.BatchStart; - var batchCount = submesh.BatchCount; - if (batchStart + batchCount > component0D.Count) + return MshType.Unknown; + } + + private static bool HasComponent(NResArchive archive, string hexType) + { + return archive.Files.Any(x => x.FileType.Equals($"{hexType} 00 00 00", StringComparison.OrdinalIgnoreCase)); + } + + private static void ConvertModel( + FileStream fs, + NResArchive archive, + string outputPath, + int lod, + int group) + { + var nodes = Msh0x01.ReadComponent(fs, archive); + var geometry = Msh0x02.ReadComponent(fs, archive); + var vertices = Msh0x03.ReadComponent(fs, archive); + var indices = Msh0x06.ReadComponent(fs, archive); + var batches = Msh0x0D.ReadComponent(fs, archive); + + using var writer = CreateObjWriter(outputPath); + + writer.WriteLine($"# MSH model converted from {Path.GetFileName(outputPath)}"); + writer.WriteLine($"# LOD: {lod}, group: {group}"); + writer.WriteLine($"# Nodes: {nodes.Nodes.Count}"); + writer.WriteLine($"# Geometry slots: {geometry.Slots.Count}"); + writer.WriteLine($"# Batches: {batches.Count}"); + writer.WriteLine($"# Vertices: {vertices.Count}"); + writer.WriteLine(); + + WriteVertices(writer, vertices); + + var exportedFaces = 0; + var skippedSlots = 0; + var skippedBatches = 0; + var skippedFaces = 0; + + for (var pieceIndex = 0; pieceIndex < nodes.Nodes.Count; pieceIndex++) + { + var node = nodes.Nodes[pieceIndex]; + var slotIndex = node.ResolveSlotIndex(lod, group); + + if (slotIndex == ushort.MaxValue) { - Console.WriteLine($"WARNING: Batch range {batchStart}:{batchCount} out of range for piece {pieceIndex}"); + skippedSlots++; continue; } - for (var batchIdx = 0; batchIdx < batchCount; batchIdx++) + if (slotIndex >= geometry.Slots.Count) { - var batch = component0D[batchStart + batchIdx]; - var baseVertex = (int)batch.BaseVertex; - var indexStart = (int)batch.IndexStart; - var indexCount = batch.IndexCount; - if (indexStart + indexCount > component06.Count) + Warn($"Piece {pieceIndex}: geometry slot {slotIndex} out of range"); + skippedSlots++; + continue; + } + + var slot = geometry.Slots[slotIndex]; + + if (!slot.HasBatches) + { + continue; + } + + if (slot.BatchEndExclusive0x0D > batches.Count) + { + Warn($"Piece {pieceIndex}: batch range {slot.BatchStart0x0D}:{slot.BatchCount0x0D} out of range"); + skippedBatches++; + continue; + } + + writer.WriteLine(); + writer.WriteLine($"g piece_{pieceIndex}_slot_{slotIndex}"); + + for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++) + { + var batch = batches[batchIndex]; + + if (batch.IndexStart + batch.IndexCount > indices.Count) { - Console.WriteLine($"WARNING: Index range {indexStart}:{indexCount} out of range for piece {pieceIndex}"); + Warn($"Piece {pieceIndex}, batch {batchIndex}: index range {batch.IndexStart}:{batch.IndexCount} out of range"); + skippedBatches++; 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; - } + writer.WriteLine($"# batch {batchIndex}, material {batch.MaterialIndex}, flags {batch.Flags}"); - 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) + for (var i = 0; i + 2 < batch.IndexCount; i += 3) + { + var indexBase = (int)batch.IndexStart + i; + + var v1 = checked((int)batch.BaseVertex + indices[indexBase + 0]); + var v2 = checked((int)batch.BaseVertex + indices[indexBase + 1]); + var v3 = checked((int)batch.BaseVertex + indices[indexBase + 2]); + + if (!IsValidTriangle(vertices.Count, v1, v2, v3)) { - Console.WriteLine($"WARNING: Vertex index out of range in piece {pieceIndex}"); + skippedFaces++; continue; } - sw.WriteLine($"f {i1 + 1} {i2 + 1} {i3 + 1}"); + WriteFace(writer, v1, v2, v3); exportedFaces++; } + + if (batch.IndexCount % 3 != 0) + { + Warn($"Piece {pieceIndex}, batch {batchIndex}: index count {batch.IndexCount} is not divisible by 3"); + } } } - - Console.WriteLine($"Exported: {component03.Count} vertices, {exportedFaces} faces"); + + Console.WriteLine($"Exported: {vertices.Count} vertices, {exportedFaces} faces"); + Console.WriteLine($"Skipped slots: {skippedSlots}, skipped batches: {skippedBatches}, skipped faces: {skippedFaces}"); Console.WriteLine($"Output: {outputPath}"); } - /// - /// Конвертирует terrain mesh в OBJ. - /// Путь данных является terrain-гипотезой проекта: 0x01 -> 0x02 -> 0x15. - /// - private void ConvertLandscape(FileStream fs, NResArchive archive, string outputPath, int lodLevel, int group) + private static void ConvertLandscape( + FileStream fs, + NResArchive archive, + string outputPath, + int lod, + int group) { - var component01 = Msh0x01.ReadComponent(fs, archive); - var component02 = Msh0x02.ReadComponent(fs, archive); - var component03 = Msh0x03.ReadComponent(fs, archive); - var component15 = Msh0x15.ReadComponent(fs, archive); + var nodes = Msh0x01.ReadComponent(fs, archive); + var geometry = Msh0x02.ReadComponent(fs, archive); + var vertices = Msh0x03.ReadComponent(fs, archive); + var triangles = Msh0x15.ReadComponent(fs, archive); - Console.WriteLine($"Vertices: {component03.Count}"); - Console.WriteLine($"Triangles: {component15.Count}"); - Console.WriteLine($"Tiles: {component01.Elements.Count}"); - Console.WriteLine($"Submeshes: {component02.Elements.Count}"); - - using var sw = new StreamWriter(outputPath, false, new UTF8Encoding(false)); - sw.WriteLine($"# Landscape mesh converted from {Path.GetFileName(outputPath)}"); - sw.WriteLine($"# LOD level: {lodLevel}"); - sw.WriteLine($"# Tile grid: {(int)Math.Sqrt(component01.Elements.Count)}x{(int)Math.Sqrt(component01.Elements.Count)}"); + using var writer = CreateObjWriter(outputPath); - foreach (var v in component03) + writer.WriteLine($"# MSH landscape converted from {Path.GetFileName(outputPath)}"); + writer.WriteLine($"# LOD: {lod}, group: {group}"); + writer.WriteLine($"# Nodes/tiles: {nodes.Nodes.Count}"); + writer.WriteLine($"# Geometry slots: {geometry.Slots.Count}"); + writer.WriteLine($"# Triangles 0x15: {triangles.Count}"); + writer.WriteLine($"# Vertices: {vertices.Count}"); + writer.WriteLine(); + + WriteVertices(writer, vertices); + + var exportedFaces = 0; + var skippedSlots = 0; + var skippedFaces = 0; + + for (var tileIndex = 0; tileIndex < nodes.Nodes.Count; tileIndex++) { - sw.WriteLine(FormattableString.Invariant($"v {v.X:F6} {v.Y:F6} {v.Z:F6}")); - } + var node = nodes.Nodes[tileIndex]; + var slotIndex = node.ResolveSlotIndex(lod, group); - int exportedFaces = 0; - - for (var tileIdx = 0; tileIdx < component01.Elements.Count; tileIdx++) - { - var tile = component01.Elements[tileIdx]; - - var submeshIdx = tile.ResolveSlotIndex(lodLevel, group); - if (submeshIdx == 0xFFFF || submeshIdx >= component02.Elements.Count) - continue; - - sw.WriteLine($"g tile_{tileIdx}"); - - var submesh = component02.Elements[submeshIdx]; - - var triangleStart = submesh.TriStart; - var triangleCount = submesh.TriCount; - - for (var triOffset = 0; triOffset < triangleCount; triOffset++) + if (slotIndex == ushort.MaxValue) { - var triIdx = triangleStart + triOffset; - if (triIdx >= component15.Count) + skippedSlots++; + continue; + } + + if (slotIndex >= geometry.Slots.Count) + { + Warn($"Tile {tileIndex}: geometry slot {slotIndex} out of range"); + skippedSlots++; + continue; + } + + var slot = geometry.Slots[slotIndex]; + + if (!slot.HasTriangles) + { + continue; + } + + writer.WriteLine(); + writer.WriteLine($"g tile_{tileIndex}_slot_{slotIndex}"); + + for (var triIndex = slot.TriStart0x07; triIndex < slot.TriEndExclusive0x07; triIndex++) + { + if (triIndex >= triangles.Count) { - Console.WriteLine($"WARNING: Triangle index {triIdx} out of range for tile {tileIdx}"); + Warn($"Tile {tileIndex}: triangle {triIndex} out of range"); + skippedFaces++; continue; } - var tri = component15[triIdx]; - if (tri.Vertex1Index >= component03.Count || tri.Vertex2Index >= component03.Count || tri.Vertex3Index >= component03.Count) + var tri = triangles[triIndex]; + + var v1 = tri.Vertex1Index; + var v2 = tri.Vertex2Index; + var v3 = tri.Vertex3Index; + + if (!IsValidTriangle(vertices.Count, v1, v2, v3)) { - Console.WriteLine($"WARNING: Vertex index out of range for tile {tileIdx}"); + skippedFaces++; continue; } - sw.WriteLine($"f {tri.Vertex1Index + 1} {tri.Vertex2Index + 1} {tri.Vertex3Index + 1}"); + WriteFace(writer, v1, v2, v3); exportedFaces++; } } - - Console.WriteLine($"Exported: {component03.Count} vertices, {exportedFaces} faces"); + + Console.WriteLine($"Exported: {vertices.Count} vertices, {exportedFaces} faces"); + Console.WriteLine($"Skipped slots: {skippedSlots}, skipped faces: {skippedFaces}"); Console.WriteLine($"Output: {outputPath}"); } - public record Face(Vector3 P1, Vector3 P2, Vector3 P3); - - public static void ExportCube(string filePath, Vector3[] points) + private static StreamWriter CreateObjWriter(string outputPath) { - if (points.Length != 8) - throw new ArgumentException("Cube must have exactly 8 points."); + return new StreamWriter(outputPath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } - using (StreamWriter writer = new StreamWriter(filePath)) + private static void WriteVertices(StreamWriter writer, IReadOnlyList vertices) + { + foreach (var vertex in vertices) { - // Запись вершин. - foreach (var p in points) - { - writer.WriteLine($"v {p.X} {p.Y} {p.Z}"); - } - - // Запись граней: каждая грань задается четырьмя вершинами, OBJ использует индексацию с 1. - int[][] faces = new int[][] - { - 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) - { - writer.WriteLine($"f {f[0]} {f[1]} {f[2]} {f[3]}"); - } + writer.WriteLine(FormattableString.Invariant($"v {vertex.X:F6} {vertex.Y:F6} {vertex.Z:F6}")); } } - public static void ExportCubesAtPositions(string filePath, List centers, float size = 2f) + private static void WriteFace(StreamWriter writer, int v1, int v2, int v3) { - float half = size / 2f; - using (StreamWriter writer = new StreamWriter(filePath)) - { - int vertexOffset = 0; - - foreach (var c in centers) - { - // Генерация восьми вершин куба. - Vector3[] vertices = new Vector3[] - { - new Vector3(c.X - half, c.Y - half, c.Z - half), - new Vector3(c.X + half, c.Y - half, c.Z - half), - new Vector3(c.X + half, c.Y - half, c.Z + half), - new Vector3(c.X - half, c.Y - half, c.Z + half), - - new Vector3(c.X - half, c.Y + half, c.Z - half), - new Vector3(c.X + half, c.Y + half, c.Z - half), - new Vector3(c.X + half, c.Y + half, c.Z + half), - new Vector3(c.X - half, c.Y + half, c.Z + half) - }; - - // Запись вершин. - foreach (var v in vertices) - { - writer.WriteLine($"v {v.X} {v.Y} {v.Z}"); - } - - // Описание граней: индексация с 1, порядок против часовой стрелки. - int[][] faces = new int[][] - { - 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) - { - writer.WriteLine( - $"f {f[0] + vertexOffset} {f[1] + vertexOffset} {f[2] + vertexOffset} {f[3] + vertexOffset}"); - } - - vertexOffset += 8; - } - } + writer.WriteLine($"f {v1 + 1} {v2 + 1} {v3 + 1}"); } - void Export(string filePath, IEnumerable vertices, List edges) + private static bool IsValidTriangle(int vertexCount, int v1, int v2, int v3) { - using (var writer = new StreamWriter(filePath)) - { - writer.WriteLine("# Exported OBJ file"); - - // Запись вершин. - foreach (var v in vertices) - { - writer.WriteLine($"v {v.X:F2} {v.Y:F2} {v.Z:F2}"); - } - - // Запись ребер как line-элементов OBJ. - foreach (var e in edges) - { - // OBJ использует индексацию с 1. - writer.WriteLine($"l {e.Index1 + 1} {e.Index2 + 1}"); - } - } + return IsValidVertexIndex(vertexCount, v1) + && IsValidVertexIndex(vertexCount, v2) + && IsValidVertexIndex(vertexCount, v3); } -} + + private static bool IsValidVertexIndex(int vertexCount, int index) + { + return index >= 0 && index < vertexCount; + } + + private static void Warn(string message) + { + Console.WriteLine($"WARNING: {message}"); + } +} \ No newline at end of file diff --git a/README.md b/README.md index 6e92ef8..0a040c5 100644 --- a/README.md +++ b/README.md @@ -392,8 +392,8 @@ color - `0x22` - unknown (implement by CLandscape) - `0x23` - IGameSettingsRoot - `0x24` - IGameObject2 -- `0x25` - unknown (implemented by CAniMesh) -- `0x26` - unknown (implemented by CAniMesh and CControl and CWizard) +- `0x25` - ICollisionMesh (придумал сам implemented by CAniMesh) +- `0x26` - IScalable (придумал сам implemented by CAniMesh and CControl and CWizard) - `0x28` - ICollObject - `0x29` - IPhysicalModel - `0x101` - I3DRender