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