msh improvements

This commit is contained in:
bird_egop
2026-05-12 01:19:19 +03:00
parent 09bc0110d0
commit 5fc8ba53b2
7 changed files with 674 additions and 509 deletions
+120 -38
View File
@@ -5,10 +5,16 @@ namespace ParkanPlayground;
/// <summary>
/// MSH-компонент 0x01: таблица узлов модели.
/// Для обычного AniMesh node имеет size 0x26.
/// У ландшафта metadata/magic1 может иметь другой смысл, например grid_x_count.
/// </summary>
/// У ландшафта 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<Node>(elementCount);
var nodes = new List<Node>(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);
}
/// <summary>Результат чтения MSH-компонента 0x01.</summary>
/// <param name="Elements">Узлы компонента 0x01.</param>
public record Msh0x01Component(List<Node> Elements);
/// <param name="ElementSize">Размер node entry из NRes metadata.</param>
/// <param name="Nodes">Узлы компонента 0x01.</param>
public sealed record Msh0x01Component(
int ElementSize,
List<Node> Nodes)
{
public bool IsNormalAniMeshNodeTable => ElementSize == NormalElementSize;
}
/// <summary>Узел 0x01.</summary>
/// <param name="RawBytes">Сырые байты узла (length = attr3). Нужны для copy-through редких вариантов, например attr3 = 24.</param>
/// <param name="Flags">[0x00..0x02] Флаги узла</param>
/// <param name="ParentOrLink_possibly_0x02_index">[0x02..0x04] Индекс родителя или связанного узла</param>
/// <param name="AnimMapStart">[0x04..0x06] Начало блока в карте анимации Msh0x13 или 0xFFFF</param>
/// <param name="FallbackKey">[0x06..0x08] Индекс fallback-ключа в пуле ключей Msh0x08</param>
/// <param name="Msh02SlotIndicesByLodAndGroupSlotIndex">[0x08..0x26] Индексы slot в Msh0x02 по формуле lod * 5 + group</param>
public record Node(
/// <summary>Узел MSH 0x01.</summary>
/// <param name="RawBytes">Сырые байты узла. Нужны для copy-through нестандартных вариантов.</param>
/// <param name="Flags">[0x00..0x02] Флаги узла.</param>
/// <param name="ParentIndexOrLink">[0x02..0x04] Parent node index. 0xFFFF обычно значит root/no parent.</param>
/// <param name="AnimMapStart0x13">[0x04..0x06] Начало блока в MSH 0x13 animation map или 0xFFFF.</param>
/// <param name="FallbackKey0x08">[0x06..0x08] Fallback key / index в MSH 0x08.</param>
/// <param name="Msh02SlotIndicesByLodAndGroup">
/// [0x08..0x26] Индексы geometry slot в MSH 0x02.
/// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует.
/// </param>
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,
/// <summary>
/// Still uncertain. In recursive bounds/intersection paths this can suppress/alter child recursion.
/// Seen as child_node.flags &amp; 0x04.
/// </summary>
UnknownSkipChild0x04 = 0x0004,
/// <summary>
/// Stops recursive traversal into children for bounds/render/intersection helpers.
/// </summary>
StopChildTraversal = 0x0010,
/// <summary>
/// Special render-group-4 mode bit. In render group 4, selects alternate piece render mode.
/// Earlier also seen in special render/clip behavior.
/// </summary>
Group4AltRenderMode = 0x0020,
/// <summary>
/// Exclude from shadow / no shadow. CAniMesh tracks has_any_shadow_casting_piece when this bit is absent.
/// </summary>
NoShadow = 0x0040,
/// <summary>
/// Used during attached MSH load: if parent/root description contains "central", piece gets hidden/excluded flag.
/// Exact semantic name still provisional.
/// </summary>
CheckParentDescriptionCentral = 0x0800,
}
+119 -140
View File
@@ -5,189 +5,168 @@ using NResLib;
namespace ParkanPlayground;
/// <summary>
/// MSH-компонент 0x02: общий заголовок и таблица slot.
/// MSH-компонент 0x02: общий bounds header и таблица geometry slots.
/// Header size = 0x8C, slot size = 0x44.
/// </summary>
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<Slot>();
var skippedHeader = data.AsSpan(0x8c);
var slotCount = skippedHeader.Length / 0x44;
var slotBytes = span.Slice(HeaderSize);
var slotCount = slotBytes.Length / SlotSize;
var slots = new List<GeometrySlot>(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++)
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(header, slots);
}
private static Msh02Header ReadHeader(ReadOnlySpan<byte> header)
{
opaque[opaqueIndex] =
BinaryPrimitives.ReadUInt32LittleEndian(
skippedHeader.Slice(baseOffset + 0x38 + opaqueIndex * 4)
);
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)));
}
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
));
private static Vector3 ReadVector3(ReadOnlySpan<byte> 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)));
}
return new Msh0x02Component(
new Msh02Header(bb, new Sphere(center.X, center.Y, center.Z, centerW), bottom, top, xyRadius),
elements);
private static Sphere ReadSphere(ReadOnlySpan<byte> 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)));
}
/// <summary>Результат чтения MSH-компонента 0x02.</summary>
/// <param name="Header">Заголовок 0x02 (length = 0x8C).</param>
/// <param name="Elements">Slot records после заголовка.</param>
public record class Msh0x02Component(Msh02Header Header, List<Slot> Elements);
/// <param name="Header">Header 0x02, length = 0x8C.</param>
/// <param name="Slots">Geometry slots после header.</param>
public sealed record Msh0x02Component(
Msh02Header Header,
List<GeometrySlot> Slots)
{
/// <summary>
/// Backward-compatible alias, если старый код ещё использует Elements.
/// </summary>
public List<GeometrySlot> Elements => Slots;
}
/// <summary>Заголовок 0x02 (length = 0x8C).</summary>
/// <summary>Заголовок MSH 0x02, length = 0x8C.</summary>
/// <param name="BoundingBox">[0x00..0x60] Bounding box из 8 точек.</param>
/// <param name="BoundingSphere">[0x60..0x70] Bounding sphere.</param>
/// <param name="Bottom">[0x70..0x7C] Нижняя точка.</param>
/// <param name="Top">[0x7C..0x88] Верхняя точка.</param>
/// <param name="XYRadius">[0x88..0x8C] Радиус в плоскости XY.</param>
public record class Msh02Header(
/// <param name="BoundingSphere">[0x60..0x70] Bounding sphere: xyz = center, w = radius.</param>
/// <param name="Bottom">[0x70..0x7C] Нижняя/опорная точка меша.</param>
/// <param name="Top">[0x7C..0x88] Верхняя точка меша.</param>
/// <param name="XyRadius">[0x88..0x8C] Радиус/extent в плоскости XY.</param>
public sealed record Msh02Header(
BoundingBox BoundingBox,
Sphere BoundingSphere,
Vector3 Bottom,
Vector3 Top,
float XYRadius);
float XyRadius);
/// <summary>Geometry Slot 0x02 (length = 0x44).</summary>
/// <param name="TriStart">[0x00..0x02] Первый triangle descriptor в Msh0x07. Для terrain-гипотезы может быть диапазоном Msh0x15.</param>
/// <param name="TriCount">[0x02..0x04] Количество triangle descriptor в Msh0x07. Для terrain-гипотезы может быть count для Msh0x15.</param>
/// <param name="BatchStart">[0x04..0x06] Первый batch в таблице 0x0D.</param>
/// <param name="BatchCount">[0x06..0x08] Количество batch в таблице 0x0D</param>
/// <param name="LocalMinimum">[0x08..0x14] Минимум локального AABB</param>
/// <param name="LocalMaximum">[0x14..0x20] Максимум локального AABB</param>
/// <param name="BoundingSphere">[0x20..0x30] Bounding sphere</param>
/// <param name="BaseXyArea">[0x30..0x34] Базовая XY-площадь / footprint area до масштабирования.</param>
/// <param name="BaseVolume">[0x34..0x38] Базовый объём до масштабирования.</param>
/// <param name="Opaque">[0x38..0x44] 3 opaque dword</param>
public record Slot(
ushort TriStart,
ushort TriCount,
ushort BatchStart,
ushort BatchCount,
/// <summary>Geometry slot MSH 0x02, length = 0x44.</summary>
/// <param name="TriStart0x07">[0x00..0x02] Первый triangle descriptor в MSH 0x07.</param>
/// <param name="TriCount0x07">[0x02..0x04] Количество triangle descriptor / triangle range count.</param>
/// <param name="BatchStart0x0D">[0x04..0x06] Первый batch в MSH 0x0D.</param>
/// <param name="BatchCount0x0D">[0x06..0x08] Количество batch в MSH 0x0D.</param>
/// <param name="LocalMinimum">[0x08..0x14] Local AABB minimum.</param>
/// <param name="LocalMaximum">[0x14..0x20] Local AABB maximum.</param>
/// <param name="BoundingSphere">[0x20..0x30] Local bounding sphere.</param>
/// <param name="BaseXyArea">[0x30..0x34] Базовая XY-площадь / footprint area до mesh scale.</param>
/// <param name="BaseVolume">[0x34..0x38] Базовый объём до mesh scale.</param>
/// <param name="Opaque38">[0x38..0x3C] Opaque dword.</param>
/// <param name="Opaque3C">[0x3C..0x40] Opaque dword.</param>
/// <param name="Opaque40">[0x40..0x44] Opaque dword.</param>
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;
/// <summary>Bounding box заголовка: 8 точек по 3 float (length = 0x60).</summary>
public int TriEndExclusive0x07 => TriStart0x07 + TriCount0x07;
public bool HasBatches => BatchCount0x0D != 0;
public bool HasTriangles => TriCount0x07 != 0;
}
/// <summary>Bounding box заголовка: 8 точек по 3 float, length = 0x60.</summary>
/// <param name="BottomFrontLeft">[0x00..0x0C] Нижняя передняя левая точка.</param>
/// <param name="BottomFrontRight">[0x0C..0x18] Нижняя передняя правая точка.</param>
/// <param name="BottomBackRight">[0x18..0x24] Нижняя задняя правая точка.</param>
@@ -196,7 +175,7 @@ public static class Msh0x02
/// <param name="TopFrontRight">[0x3C..0x48] Верхняя передняя правая точка.</param>
/// <param name="TopBackRight">[0x48..0x54] Верхняя задняя правая точка.</param>
/// <param name="TopBackLeft">[0x54..0x60] Верхняя задняя левая точка.</param>
public record BoundingBox(
public sealed record BoundingBox(
Vector3 BottomFrontLeft,
Vector3 BottomFrontRight,
Vector3 BottomBackRight,
+59 -13
View File
@@ -4,42 +4,88 @@ using NResLib;
namespace ParkanPlayground;
/// <summary>
/// MSH-компонент 0x06: индексный буфер
/// MSH-компонент 0x06: индексный буфер.
/// Используется batch-ами 0x0D через Batch.IndexStart / Batch.IndexCount.
/// Индексы являются ushort и обычно читаются тройками как triangle indices.
/// </summary>
public static class Msh0x06
{
public static List<ushort> ReadComponent(
FileStream mshFs, NResArchive archive)
public const int ElementSize = 2;
public static List<ushort> 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<ushort>(entry.FileLength / entry.ElementSize);
for (var i = 0; i < entry.FileLength / entry.ElementSize; i++)
var span = data.AsSpan();
var indices = new List<ushort>(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;
}
/// <summary>
/// Возвращает triangle triplets из диапазона индексов.
/// Удобно для обхода batch.IndexStart / batch.IndexCount из 0x0D.
/// </summary>
public static IEnumerable<TriangleIndices> EnumerateTriangles(
IReadOnlyList<ushort> 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]);
}
}
/// <summary>Тройка индексов triangle из MSH 0x06.</summary>
/// <param name="A">Первый vertex index внутри batch/base vertex range.</param>
/// <param name="B">Второй vertex index внутри batch/base vertex range.</param>
/// <param name="C">Третий vertex index внутри batch/base vertex range.</param>
public readonly record struct TriangleIndices(
ushort A,
ushort B,
ushort C);
}
+67 -32
View File
@@ -1,71 +1,88 @@
using System.Buffers.Binary;
using Common;
using NResLib;
namespace ParkanPlayground;
/// <summary>
/// MSH-компонент 0x07: описатели треугольников для коллизии/пикинга
/// MSH-компонент 0x07: triangle descriptors.
/// Используется geometry walker-ами для raycast / point-inside / фильтрации triangle flags.
/// Геометрические vertex indices лежат не здесь, а в MSH 0x06.
/// </summary>
public static class Msh0x07
{
public static List<TriangleDescriptor> ReadComponent(
FileStream mshFs, NResArchive archive)
public const int ElementSize = 0x10;
public const float PackedNormalScale = 1.0f / 32767.0f;
public static List<TriangleDescriptor> 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<TriangleDescriptor>(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))));
}
/// <summary>Описатель треугольника 0x07 (length = 0x10).</summary>
/// <param name="TriFlags">[0x00..0x02] Флаги треугольника</param>
/// <param name="Link0">[0x02..0x04] Связь/opaque поле 0</param>
/// <param name="Link1">[0x04..0x06] Связь/opaque поле 1</param>
/// <param name="Link2">[0x06..0x08] Связь/opaque поле 2</param>
/// <param name="NormalX">[0x08..0x0A] Упакованная X-компонента нормали</param>
/// <param name="NormalY">[0x0A..0x0C] Упакованная Y-компонента нормали</param>
/// <param name="NormalZ">[0x0C..0x0E] Упакованная Z-компонента нормали</param>
/// <param name="SelectorPacked">[0x0E..0x10] Три 2-битных селектора; значение 3 трактуется как 0xFFFF</param>
return descriptors;
}
/// <summary>Описатель triangle MSH 0x07, length = 0x10.</summary>
/// <param name="Flags">[0x00..0x02] Triangle flags. Используются GeometryWalkFilter require/exclude.</param>
/// <param name="Link0">[0x02..0x04] Opaque/link поле 0.</param>
/// <param name="Link1">[0x04..0x06] Opaque/link поле 1.</param>
/// <param name="Link2">[0x06..0x08] Opaque/link поле 2.</param>
/// <param name="PackedNormalX">[0x08..0x0A] Packed normal X, int16, scale = 1 / 32767.</param>
/// <param name="PackedNormalY">[0x0A..0x0C] Packed normal Y, int16, scale = 1 / 32767.</param>
/// <param name="PackedNormalZ">[0x0C..0x0E] Packed normal Z, int16, scale = 1 / 32767.</param>
/// <param name="SelectorPacked">[0x0E..0x10] Packed selectors. Старое наблюдение: 3 трактуется как 0xFFFF.</param>
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.
}
+78 -38
View File
@@ -4,14 +4,14 @@ using NResLib;
namespace ParkanPlayground;
/// <summary>
/// MSH-компонент 0x0D: таблица batch.
/// MSH-компонент 0x0D: таблица render/intersection batches.
/// Используется через MSH_02_geometry_slot.batch_start_0x0d / batch_count_0x0d.
/// </summary>
public static class Msh0x0D
{
public const int ElementSize = 20;
public static List<Batch> ReadComponent(
FileStream mshFs, NResArchive archive)
public static List<Batch> 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();
}
/// <summary>Batch 0x0D</summary>
/// <param name="BatchFlags">[0x00..0x02] Флаги batch. У FParkan: batchFlags.</param>
/// <param name="MaterialIndex">[0x02..0x04] Индекс material slot, резолвится через WEAR/MAT0 pipeline.</param>
/// <param name="Opaque4">[0x04..0x06] Opaque поле. Старое локальное имя: TriangleCount; не подтверждено.</param>
/// <param name="Opaque6">[0x06..0x08] Opaque поле. Сохранять побайтно в writer.</param>
/// <param name="IndexCount">[0x08..0x0A] Количество индексов в индексном буфере 0x06.</param>
/// <param name="IndexStart">[0x0A..0x0E] Первый индекс в индексном буфере 0x06.</param>
/// <param name="Opaque14">[0x0E..0x10] Opaque поле. Старое локальное имя: CountOf03; не подтверждено.</param>
/// <param name="BaseVertex">[0x10..0x14] Базовая вершина в position stream 0x03. У FParkan: baseVertex.</param>
/// <summary>MSH batch 0x0D, sizeof 0x14.</summary>
/// <param name="Flags">[0x00..0x02] Флаги batch.</param>
/// <param name="MaterialIndex">[0x02..0x04] Индекс material slot. Может быть overridden CAniMesh::forced_material_index.</param>
/// <param name="Opaque04">[0x04..0x06] Opaque. В GetBatchRenderData попадает в packed field вместе с material/lightmap данными.</param>
/// <param name="LocalBatchIndex">[0x06..0x08] Локальный batch index. В GetBatchRenderData складывается с MSH_piece.local_index_base / batch base.</param>
/// <param name="IndexCount">[0x08..0x0A] Количество индексов из component 0x06.</param>
/// <param name="IndexStart">[0x0A..0x0E] Первый индекс в component 0x06.</param>
/// <param name="VertexCount">[0x0E..0x10] Количество вершин для render primitive.</param>
/// <param name="BaseVertex">[0x10..0x14] Base vertex в vertex streams, включая position stream 0x03.</param>
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,
/// <summary>
/// Special/immediate submit path in CShade::SubmitMeshPieceBatches.
/// </summary>
SpecialSubmit = 0x0001,
/// <summary>
/// Intersection path tries both facing directions / disables facing test.
/// Also likely related to two-sided handling.
/// </summary>
DisableFacingTest = 0x0002,
/// <summary>
/// Use material phase path instead of normal material animation frame.
/// </summary>
UseMaterialPhase = 0x0004,
/// <summary>
/// Special pass / conditional batch logic.
/// In GetBatchRenderData this participates in conditional suppression logic.
/// </summary>
SpecialPassOrConditional = 0x0008,
/// <summary>
/// Batch is suppressed when paired with SpecialPassOrConditional.
/// </summary>
SuppressBatch = 0x0020,
/// <summary>
/// Forces second/translucent-ish render pass. Exact render-state meaning still provisional.
/// </summary>
ForcePass2 = 0x0100,
/// <summary>
/// Enables point-light emulation path.
/// </summary>
EmulatePointLights = 0x0800,
/// <summary>
/// Batch has lightmap / secondary texcoord related data.
/// </summary>
HasLightmapOrTexcoord1 = 0x2000,
/// <summary>
/// Facing/culling mode bit. Exact render meaning is still provisional.
/// </summary>
FacingTestEarlyOut = 0x4000,
}
+208 -225
View File
@@ -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
/// <summary>
/// Для обычной модели минимальный геометрический путь сейчас выглядит так:
/// 0x01 node
/// -> 0x02 geometry slot
/// -> 0x0D batch
/// -> 0x06 indices
/// -> 0x03 positions
/// </summary>
Model,
Landscape
}
public class MshConverter
public sealed class MshConverter
{
/// <summary>Определяет тип MSH по набору hex-компонентов архива.</summary>
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;
}
/// <summary>Конвертирует .msh в OBJ с автоопределением типа меша.</summary>
/// <param name="mshPath">Путь к .msh файлу.</param>
/// <param name="outputPath">Путь к OBJ, по умолчанию рядом с исходным файлом.</param>
/// <param name="lodLevel">LOD для экспорта.</param>
/// <param name="group">Группа slot внутри LOD. slotIndex[lod * 5 + group].</param>
public void Convert(string mshPath, string? outputPath = null, int lodLevel = 0, int group = 0)
var result = NResParser.ReadFile(mshPath);
if (result.Archive is null)
{
var mshNresResult = NResParser.ReadFile(mshPath);
if (mshNresResult.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;
}
}
/// <summary>
/// Конвертирует обычную модель в OBJ.
/// Путь данных: 0x01 -> 0x02 -> 0x0D -> 0x06 -> 0x03.
/// </summary>
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];
return MshType.Landscape;
}
var submeshIdx = piece.ResolveSlotIndex(lodLevel, group);
if (submeshIdx == 0xFFFF || submeshIdx >= component02.Elements.Count)
continue;
return MshType.Unknown;
}
sw.WriteLine($"g piece_{pieceIndex}");
var submesh = component02.Elements[submeshIdx];
var batchStart = submesh.BatchStart;
var batchCount = submesh.BatchCount;
if (batchStart + batchCount > component0D.Count)
private static bool HasComponent(NResArchive archive, string hexType)
{
Console.WriteLine($"WARNING: Batch range {batchStart}:{batchCount} out of range for piece {pieceIndex}");
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)
{
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)
{
Console.WriteLine($"WARNING: Index range {indexStart}:{indexCount} out of range for piece {pieceIndex}");
Warn($"Piece {pieceIndex}: geometry slot {slotIndex} out of range");
skippedSlots++;
continue;
}
for (int i = 0; i < indexCount; i += 3)
{
if (i + 2 >= indexCount)
{
Console.WriteLine($"WARNING: Batch has non-triangle index tail in piece {pieceIndex}");
break;
}
var slot = geometry.Slots[slotIndex];
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)
if (!slot.HasBatches)
{
Console.WriteLine($"WARNING: Vertex index out of range in piece {pieceIndex}");
continue;
}
sw.WriteLine($"f {i1 + 1} {i2 + 1} {i3 + 1}");
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)
{
Warn($"Piece {pieceIndex}, batch {batchIndex}: index range {batch.IndexStart}:{batch.IndexCount} out of range");
skippedBatches++;
continue;
}
writer.WriteLine($"# batch {batchIndex}, material {batch.MaterialIndex}, flags {batch.Flags}");
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))
{
skippedFaces++;
continue;
}
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}");
}
/// <summary>
/// Конвертирует terrain mesh в OBJ.
/// Путь данных является terrain-гипотезой проекта: 0x01 -> 0x02 -> 0x15.
/// </summary>
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 writer = CreateObjWriter(outputPath);
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)}");
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();
foreach (var v in component03)
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++)
if (slotIndex == ushort.MaxValue)
{
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++)
{
var triIdx = triangleStart + triOffset;
if (triIdx >= component15.Count)
{
Console.WriteLine($"WARNING: Triangle index {triIdx} out of range for tile {tileIdx}");
skippedSlots++;
continue;
}
var tri = component15[triIdx];
if (tri.Vertex1Index >= component03.Count || tri.Vertex2Index >= component03.Count || tri.Vertex3Index >= component03.Count)
if (slotIndex >= geometry.Slots.Count)
{
Console.WriteLine($"WARNING: Vertex index out of range for tile {tileIdx}");
Warn($"Tile {tileIndex}: geometry slot {slotIndex} out of range");
skippedSlots++;
continue;
}
sw.WriteLine($"f {tri.Vertex1Index + 1} {tri.Vertex2Index + 1} {tri.Vertex3Index + 1}");
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)
{
Warn($"Tile {tileIndex}: triangle {triIndex} out of range");
skippedFaces++;
continue;
}
var tri = triangles[triIndex];
var v1 = tri.Vertex1Index;
var v2 = tri.Vertex2Index;
var v3 = tri.Vertex3Index;
if (!IsValidTriangle(vertices.Count, v1, v2, v3))
{
skippedFaces++;
continue;
}
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.");
using (StreamWriter writer = new StreamWriter(filePath))
{
// Запись вершин.
foreach (var p in points)
{
writer.WriteLine($"v {p.X} {p.Y} {p.Z}");
return new StreamWriter(outputPath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
// Запись граней: каждая грань задается четырьмя вершинами, OBJ использует индексацию с 1.
int[][] faces = new int[][]
private static void WriteVertices(StreamWriter writer, IReadOnlyList<Vector3> vertices)
{
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)
foreach (var vertex in vertices)
{
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<Vector3> 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}");
writer.WriteLine($"f {v1 + 1} {v2 + 1} {v3 + 1}");
}
// Описание граней: индексация с 1, порядок против часовой стрелки.
int[][] faces = new int[][]
private static bool IsValidTriangle(int vertexCount, int v1, int v2, int v3)
{
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}");
return IsValidVertexIndex(vertexCount, v1)
&& IsValidVertexIndex(vertexCount, v2)
&& IsValidVertexIndex(vertexCount, v3);
}
vertexOffset += 8;
}
}
private static bool IsValidVertexIndex(int vertexCount, int index)
{
return index >= 0 && index < vertexCount;
}
void Export(string filePath, IEnumerable<Vector3> vertices, List<IndexedEdge> edges)
private static void Warn(string message)
{
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}");
}
}
Console.WriteLine($"WARNING: {message}");
}
}
+2 -2
View File
@@ -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