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> /// <summary>
/// MSH-компонент 0x01: таблица узлов модели. /// MSH-компонент 0x01: таблица узлов модели.
/// Для обычного AniMesh node имеет size 0x26.
/// У ландшафта metadata/magic1 может иметь другой смысл, например grid_x_count.
/// </summary> /// </summary>
/// У ландшафта magic1 - grid_x_count
public static class Msh0x01 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) public static Msh0x01Component ReadComponent(FileStream mshFs, NResArchive archive)
{ {
var entry = archive.Files.FirstOrDefault(x => x.FileType == "01 00 00 00"); 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"); 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 elementCount = entry.FileLength / entry.ElementSize;
var data = new byte[entry.FileLength]; var data = new byte[entry.FileLength];
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
mshFs.ReadExactly(data, 0, data.Length); mshFs.ReadExactly(data, 0, data.Length);
var dataSpan = data.AsSpan(); var dataSpan = data.AsSpan();
var elements = new List<Node>(elementCount); var nodes = new List<Node>(elementCount);
for (var i = 0; i < elementCount; i++) for (var i = 0; i < elementCount; i++)
{ {
var baseOffset = i * entry.ElementSize; var baseOffset = i * entry.ElementSize;
var rawBytes = dataSpan.Slice(baseOffset, entry.ElementSize).ToArray(); var elementSpan = dataSpan.Slice(baseOffset, entry.ElementSize);
var slots = new ushort[15];
Array.Fill(slots, ushort.MaxValue); var rawBytes = elementSpan.ToArray();
var slotWords = Math.Min(slots.Length, Math.Max(0, (entry.ElementSize - 8) / 2));
for (var slotIndex = 0; slotIndex < slotWords; slotIndex++) 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] = slotIndices[slotIndex] =
BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 8 + slotIndex * 2)); BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x08 + slotIndex * 2, 2));
} }
elements.Add(new Node( nodes.Add(new Node(
rawBytes, RawBytes: rawBytes,
(NodeFlags)BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset)), Flags: (NodeFlags)BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x00, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 2)), ParentIndexOrLink: BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x02, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 4)), AnimMapStart0x13: BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x04, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(dataSpan.Slice(baseOffset + 6)), FallbackKey0x08: BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x06, 2)),
slots)); Msh02SlotIndicesByLodAndGroup: slotIndices));
} }
return new Msh0x01Component(elements); return new Msh0x01Component(entry.ElementSize, nodes);
} }
/// <summary>Результат чтения MSH-компонента 0x01.</summary> /// <summary>Результат чтения MSH-компонента 0x01.</summary>
/// <param name="Elements">Узлы компонента 0x01.</param> /// <param name="ElementSize">Размер node entry из NRes metadata.</param>
public record Msh0x01Component(List<Node> Elements); /// <param name="Nodes">Узлы компонента 0x01.</param>
public sealed record Msh0x01Component(
int ElementSize,
List<Node> Nodes)
{
public bool IsNormalAniMeshNodeTable => ElementSize == NormalElementSize;
}
/// <summary>Узел 0x01.</summary> /// <summary>Узел MSH 0x01.</summary>
/// <param name="RawBytes">Сырые байты узла (length = attr3). Нужны для copy-through редких вариантов, например attr3 = 24.</param> /// <param name="RawBytes">Сырые байты узла. Нужны для copy-through нестандартных вариантов.</param>
/// <param name="Flags">[0x00..0x02] Флаги узла</param> /// <param name="Flags">[0x00..0x02] Флаги узла.</param>
/// <param name="ParentOrLink_possibly_0x02_index">[0x02..0x04] Индекс родителя или связанного узла</param> /// <param name="ParentIndexOrLink">[0x02..0x04] Parent node index. 0xFFFF обычно значит root/no parent.</param>
/// <param name="AnimMapStart">[0x04..0x06] Начало блока в карте анимации Msh0x13 или 0xFFFF</param> /// <param name="AnimMapStart0x13">[0x04..0x06] Начало блока в MSH 0x13 animation map или 0xFFFF.</param>
/// <param name="FallbackKey">[0x06..0x08] Индекс fallback-ключа в пуле ключей Msh0x08</param> /// <param name="FallbackKey0x08">[0x06..0x08] Fallback key / index в MSH 0x08.</param>
/// <param name="Msh02SlotIndicesByLodAndGroupSlotIndex">[0x08..0x26] Индексы slot в Msh0x02 по формуле lod * 5 + group</param> /// <param name="Msh02SlotIndicesByLodAndGroup">
public record Node( /// [0x08..0x26] Индексы geometry slot в MSH 0x02.
/// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует.
/// </param>
public sealed record Node(
byte[] RawBytes, byte[] RawBytes,
NodeFlags Flags, NodeFlags Flags,
ushort ParentOrLink_possibly_0x02_index, ushort ParentIndexOrLink,
ushort AnimMapStart, ushort AnimMapStart0x13,
ushort FallbackKey, ushort FallbackKey0x08,
ushort[] Msh02SlotIndicesByLodAndGroupSlotIndex) ushort[] Msh02SlotIndicesByLodAndGroup)
{ {
public bool IsRoot => ParentIndexOrLink == ushort.MaxValue;
public int ParentIndexOrMinusOne =>
ParentIndexOrLink == ushort.MaxValue ? -1 : ParentIndexOrLink;
public ushort ResolveSlotIndex(int lod, int group = 0) public ushort ResolveSlotIndex(int lod, int group = 0)
{ {
var index = lod * 5 + group; var index = lod * GroupCount + group;
return index >= 0 && index < Msh02SlotIndicesByLodAndGroupSlotIndex.Length ? Msh02SlotIndicesByLodAndGroupSlotIndex[index] : ushort.MaxValue;
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 public enum NodeFlags : ushort
{ {
// very uncertain None = 0,
MSH01_NODE_FLAG_UNKNOWN_SKIP_RECURSE_0x04 = 0x4,
MSH01_NODE_FLAG_STOP_CHILD_TRAVERSAL = 0x10, /// <summary>
MSH01_NODE_FLAG_NO_SHADOW = 0x40 /// 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,
} }
+123 -144
View File
@@ -5,189 +5,168 @@ using NResLib;
namespace ParkanPlayground; namespace ParkanPlayground;
/// <summary> /// <summary>
/// MSH-компонент 0x02: общий заголовок и таблица slot. /// MSH-компонент 0x02: общий bounds header и таблица geometry slots.
/// Header size = 0x8C, slot size = 0x44.
/// </summary> /// </summary>
public static class Msh0x02 public static class Msh0x02
{ {
public const int HeaderSize = 0x8C;
public const int SlotSize = 0x44;
public static Msh0x02Component ReadComponent(FileStream mshFs, NResArchive archive) 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]; var data = new byte[entry.FileLength];
mshFs.Seek(fileEntry.OffsetInFile, SeekOrigin.Begin);
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
mshFs.ReadExactly(data, 0, data.Length); mshFs.ReadExactly(data, 0, data.Length);
var header = data.AsSpan(0, 0x8c); // заголовок (length = 0x8C) var span = data.AsSpan();
var center = new Vector3( var header = ReadHeader(span.Slice(0, HeaderSize));
BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x60)),
BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x64)),
BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x68))
);
var centerW = BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0x6c));
var bb = new BoundingBox( var slotBytes = span.Slice(HeaderSize);
new Vector3( var slotCount = slotBytes.Length / SlotSize;
BinaryPrimitives.ReadSingleLittleEndian(header.Slice(0)), var slots = new List<GeometrySlot>(slotCount);
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;
for (var i = 0; i < slotCount; i++) for (var i = 0; i < slotCount; i++)
{ {
var baseOffset = 0x44 * i; var slot = slotBytes.Slice(i * SlotSize, SlotSize);
var opaque = new uint[3]; slots.Add(new GeometrySlot(
for (var opaqueIndex = 0; opaqueIndex < opaque.Length; opaqueIndex++) TriStart0x07: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x00, 2)),
{ TriCount0x07: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x02, 2)),
opaque[opaqueIndex] = BatchStart0x0D: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x04, 2)),
BinaryPrimitives.ReadUInt32LittleEndian( BatchCount0x0D: BinaryPrimitives.ReadUInt16LittleEndian(slot.Slice(0x06, 2)),
skippedHeader.Slice(baseOffset + 0x38 + opaqueIndex * 4) LocalMinimum: ReadVector3(slot, 0x08),
); LocalMaximum: ReadVector3(slot, 0x14),
} BoundingSphere: ReadSphere(slot, 0x20),
BaseXyArea: BinaryPrimitives.ReadSingleLittleEndian(slot.Slice(0x30, 4)),
elements.Add(new Slot( BaseVolume: BinaryPrimitives.ReadSingleLittleEndian(slot.Slice(0x34, 4)),
BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x00)), Opaque38: BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(0x38, 4)),
BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x02)), Opaque3C: BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(0x3C, 4)),
BinaryPrimitives.ReadUInt16LittleEndian(skippedHeader.Slice(baseOffset + 0x04)), Opaque40: BinaryPrimitives.ReadUInt32LittleEndian(slot.Slice(0x40, 4))));
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
));
} }
return new Msh0x02Component( return new Msh0x02Component(header, slots);
new Msh02Header(bb, new Sphere(center.X, center.Y, center.Z, centerW), bottom, top, xyRadius), }
elements);
private static Msh02Header ReadHeader(ReadOnlySpan<byte> 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<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)));
}
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> /// <summary>Результат чтения MSH-компонента 0x02.</summary>
/// <param name="Header">Заголовок 0x02 (length = 0x8C).</param> /// <param name="Header">Header 0x02, length = 0x8C.</param>
/// <param name="Elements">Slot records после заголовка.</param> /// <param name="Slots">Geometry slots после header.</param>
public record class Msh0x02Component(Msh02Header Header, List<Slot> Elements); 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="BoundingBox">[0x00..0x60] Bounding box из 8 точек.</param>
/// <param name="BoundingSphere">[0x60..0x70] Bounding sphere.</param> /// <param name="BoundingSphere">[0x60..0x70] Bounding sphere: xyz = center, w = radius.</param>
/// <param name="Bottom">[0x70..0x7C] Нижняя точка.</param> /// <param name="Bottom">[0x70..0x7C] Нижняя/опорная точка меша.</param>
/// <param name="Top">[0x7C..0x88] Верхняя точка.</param> /// <param name="Top">[0x7C..0x88] Верхняя точка меша.</param>
/// <param name="XYRadius">[0x88..0x8C] Радиус в плоскости XY.</param> /// <param name="XyRadius">[0x88..0x8C] Радиус/extent в плоскости XY.</param>
public record class Msh02Header( public sealed record Msh02Header(
BoundingBox BoundingBox, BoundingBox BoundingBox,
Sphere BoundingSphere, Sphere BoundingSphere,
Vector3 Bottom, Vector3 Bottom,
Vector3 Top, Vector3 Top,
float XYRadius); float XyRadius);
/// <summary>Geometry Slot 0x02 (length = 0x44).</summary> /// <summary>Geometry slot MSH 0x02, length = 0x44.</summary>
/// <param name="TriStart">[0x00..0x02] Первый triangle descriptor в Msh0x07. Для terrain-гипотезы может быть диапазоном Msh0x15.</param> /// <param name="TriStart0x07">[0x00..0x02] Первый triangle descriptor в MSH 0x07.</param>
/// <param name="TriCount">[0x02..0x04] Количество triangle descriptor в Msh0x07. Для terrain-гипотезы может быть count для Msh0x15.</param> /// <param name="TriCount0x07">[0x02..0x04] Количество triangle descriptor / triangle range count.</param>
/// <param name="BatchStart">[0x04..0x06] Первый batch в таблице 0x0D.</param> /// <param name="BatchStart0x0D">[0x04..0x06] Первый batch в MSH 0x0D.</param>
/// <param name="BatchCount">[0x06..0x08] Количество batch в таблице 0x0D</param> /// <param name="BatchCount0x0D">[0x06..0x08] Количество batch в MSH 0x0D.</param>
/// <param name="LocalMinimum">[0x08..0x14] Минимум локального AABB</param> /// <param name="LocalMinimum">[0x08..0x14] Local AABB minimum.</param>
/// <param name="LocalMaximum">[0x14..0x20] Максимум локального AABB</param> /// <param name="LocalMaximum">[0x14..0x20] Local AABB maximum.</param>
/// <param name="BoundingSphere">[0x20..0x30] Bounding sphere</param> /// <param name="BoundingSphere">[0x20..0x30] Local bounding sphere.</param>
/// <param name="BaseXyArea">[0x30..0x34] Базовая XY-площадь / footprint area до масштабирования.</param> /// <param name="BaseXyArea">[0x30..0x34] Базовая XY-площадь / footprint area до mesh scale.</param>
/// <param name="BaseVolume">[0x34..0x38] Базовый объём до масштабирования.</param> /// <param name="BaseVolume">[0x34..0x38] Базовый объём до mesh scale.</param>
/// <param name="Opaque">[0x38..0x44] 3 opaque dword</param> /// <param name="Opaque38">[0x38..0x3C] Opaque dword.</param>
public record Slot( /// <param name="Opaque3C">[0x3C..0x40] Opaque dword.</param>
ushort TriStart, /// <param name="Opaque40">[0x40..0x44] Opaque dword.</param>
ushort TriCount, public readonly record struct GeometrySlot(
ushort BatchStart, ushort TriStart0x07,
ushort BatchCount, ushort TriCount0x07,
ushort BatchStart0x0D,
ushort BatchCount0x0D,
Vector3 LocalMinimum, Vector3 LocalMinimum,
Vector3 LocalMaximum, Vector3 LocalMaximum,
Sphere BoundingSphere, Sphere BoundingSphere,
float BaseXyArea, float BaseXyArea,
float BaseVolume, 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="BottomFrontLeft">[0x00..0x0C] Нижняя передняя левая точка.</param>
/// <param name="BottomFrontRight">[0x0C..0x18] Нижняя передняя правая точка.</param> /// <param name="BottomFrontRight">[0x0C..0x18] Нижняя передняя правая точка.</param>
/// <param name="BottomBackRight">[0x18..0x24] Нижняя задняя правая точка.</param> /// <param name="BottomBackRight">[0x18..0x24] Нижняя задняя правая точка.</param>
@@ -196,7 +175,7 @@ public static class Msh0x02
/// <param name="TopFrontRight">[0x3C..0x48] Верхняя передняя правая точка.</param> /// <param name="TopFrontRight">[0x3C..0x48] Верхняя передняя правая точка.</param>
/// <param name="TopBackRight">[0x48..0x54] Верхняя задняя правая точка.</param> /// <param name="TopBackRight">[0x48..0x54] Верхняя задняя правая точка.</param>
/// <param name="TopBackLeft">[0x54..0x60] Верхняя задняя левая точка.</param> /// <param name="TopBackLeft">[0x54..0x60] Верхняя задняя левая точка.</param>
public record BoundingBox( public sealed record BoundingBox(
Vector3 BottomFrontLeft, Vector3 BottomFrontLeft,
Vector3 BottomFrontRight, Vector3 BottomFrontRight,
Vector3 BottomBackRight, Vector3 BottomBackRight,
@@ -205,4 +184,4 @@ public static class Msh0x02
Vector3 TopFrontRight, Vector3 TopFrontRight,
Vector3 TopBackRight, Vector3 TopBackRight,
Vector3 TopBackLeft); Vector3 TopBackLeft);
} }
+60 -14
View File
@@ -4,42 +4,88 @@ using NResLib;
namespace ParkanPlayground; namespace ParkanPlayground;
/// <summary> /// <summary>
/// MSH-компонент 0x06: индексный буфер /// MSH-компонент 0x06: индексный буфер.
/// Используется batch-ами 0x0D через Batch.IndexStart / Batch.IndexCount.
/// Индексы являются ushort и обычно читаются тройками как triangle indices.
/// </summary> /// </summary>
public static class Msh0x06 public static class Msh0x06
{ {
public static List<ushort> ReadComponent( public const int ElementSize = 2;
FileStream mshFs, NResArchive archive)
public static List<ushort> ReadComponent(FileStream mshFs, NResArchive archive)
{ {
var entry = archive.Files.FirstOrDefault(x => x.FileType == "06 00 00 00"); var entry = archive.Files.FirstOrDefault(x => x.FileType == "06 00 00 00");
if (entry is null) 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"); 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]; var data = new byte[entry.FileLength];
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
mshFs.ReadExactly(data, 0, data.Length); mshFs.ReadExactly(data, 0, data.Length);
var elements = new List<ushort>(entry.FileLength / entry.ElementSize); var span = data.AsSpan();
for (var i = 0; i < entry.FileLength / entry.ElementSize; i++) var indices = new List<ushort>(entry.FileLength / ElementSize);
for (var offset = 0; offset < span.Length; offset += ElementSize)
{ {
elements.Add( indices.Add(BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, ElementSize)));
BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(i * 2))
);
} }
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 System.Buffers.Binary;
using Common;
using NResLib; using NResLib;
namespace ParkanPlayground; namespace ParkanPlayground;
/// <summary> /// <summary>
/// MSH-компонент 0x07: описатели треугольников для коллизии/пикинга /// MSH-компонент 0x07: triangle descriptors.
/// Используется geometry walker-ами для raycast / point-inside / фильтрации triangle flags.
/// Геометрические vertex indices лежат не здесь, а в MSH 0x06.
/// </summary> /// </summary>
public static class Msh0x07 public static class Msh0x07
{ {
public static List<TriangleDescriptor> ReadComponent( public const int ElementSize = 0x10;
FileStream mshFs, NResArchive archive) 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"); var entry = archive.Files.FirstOrDefault(x => x.FileType == "07 00 00 00");
if (entry is null) 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"); 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]; var data = new byte[entry.FileLength];
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
mshFs.ReadExactly(data, 0, data.Length); 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( for (var offset = 0; offset < span.Length; offset += ElementSize)
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0)), {
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(2)), var element = span.Slice(offset, ElementSize);
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(4)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(6)),
BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(8)),
BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(10)),
BinaryPrimitives.ReadInt16LittleEndian(x.AsSpan(12)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(14)))).ToList();
return elements; 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;
} }
/// <summary>Описатель треугольника 0x07 (length = 0x10).</summary> /// <summary>Описатель triangle MSH 0x07, length = 0x10.</summary>
/// <param name="TriFlags">[0x00..0x02] Флаги треугольника</param> /// <param name="Flags">[0x00..0x02] Triangle flags. Используются GeometryWalkFilter require/exclude.</param>
/// <param name="Link0">[0x02..0x04] Связь/opaque поле 0</param> /// <param name="Link0">[0x02..0x04] Opaque/link поле 0.</param>
/// <param name="Link1">[0x04..0x06] Связь/opaque поле 1</param> /// <param name="Link1">[0x04..0x06] Opaque/link поле 1.</param>
/// <param name="Link2">[0x06..0x08] Связь/opaque поле 2</param> /// <param name="Link2">[0x06..0x08] Opaque/link поле 2.</param>
/// <param name="NormalX">[0x08..0x0A] Упакованная X-компонента нормали</param> /// <param name="PackedNormalX">[0x08..0x0A] Packed normal X, int16, scale = 1 / 32767.</param>
/// <param name="NormalY">[0x0A..0x0C] Упакованная Y-компонента нормали</param> /// <param name="PackedNormalY">[0x0A..0x0C] Packed normal Y, int16, scale = 1 / 32767.</param>
/// <param name="NormalZ">[0x0C..0x0E] Упакованная Z-компонента нормали</param> /// <param name="PackedNormalZ">[0x0C..0x0E] Packed normal Z, int16, scale = 1 / 32767.</param>
/// <param name="SelectorPacked">[0x0E..0x10] Три 2-битных селектора; значение 3 трактуется как 0xFFFF</param> /// <param name="SelectorPacked">[0x0E..0x10] Packed selectors. Старое наблюдение: 3 трактуется как 0xFFFF.</param>
public readonly record struct TriangleDescriptor( public readonly record struct TriangleDescriptor(
ushort TriFlags, TriangleFlags Flags,
ushort Link0, ushort Link0,
ushort Link1, ushort Link1,
ushort Link2, ushort Link2,
short NormalX, short PackedNormalX,
short NormalY, short PackedNormalY,
short NormalZ, short PackedNormalZ,
ushort SelectorPacked) ushort SelectorPacked)
{ {
public Vector3 Normal => new(
PackedNormalX * PackedNormalScale,
PackedNormalY * PackedNormalScale,
PackedNormalZ * PackedNormalScale);
public ushort GetSelector(int index) public ushort GetSelector(int index)
{ {
if (index is < 0 or > 2) if (index is < 0 or > 2)
@@ -74,7 +91,25 @@ public static class Msh0x07
} }
var selector = (SelectorPacked >> (index * 2)) & 0b11; 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.
}
+79 -39
View File
@@ -4,14 +4,14 @@ using NResLib;
namespace ParkanPlayground; namespace ParkanPlayground;
/// <summary> /// <summary>
/// MSH-компонент 0x0D: таблица batch. /// MSH-компонент 0x0D: таблица render/intersection batches.
/// Используется через MSH_02_geometry_slot.batch_start_0x0d / batch_count_0x0d.
/// </summary> /// </summary>
public static class Msh0x0D public static class Msh0x0D
{ {
public const int ElementSize = 20; public const int ElementSize = 20;
public static List<Batch> ReadComponent( public static List<Batch> ReadComponent(FileStream mshFs, NResArchive archive)
FileStream mshFs, NResArchive archive)
{ {
var entry = archive.Files.FirstOrDefault(x => x.FileType == "0D 00 00 00"); 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]; var data = new byte[entry.FileLength];
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
mshFs.ReadExactly(data, 0, data.Length); mshFs.ReadExactly(data, 0, data.Length);
var elementBytes = data.Chunk(ElementSize); return data
.Chunk(ElementSize)
var elements = elementBytes.Select(x => new Batch( .Select(x => new Batch(
(BatchFlags)BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0)), (BatchFlags)BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x00)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(2)), BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x02)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(4)), BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x04)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(6)), BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x06)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(8)), BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x08)),
BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0xA)), BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x0A)),
BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0xE)), BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x0E)),
BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x10)))).ToList(); BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x10))))
.ToList();
return elements;
} }
/// <summary>Batch 0x0D</summary> /// <summary>MSH batch 0x0D, sizeof 0x14.</summary>
/// <param name="BatchFlags">[0x00..0x02] Флаги batch. У FParkan: batchFlags.</param> /// <param name="Flags">[0x00..0x02] Флаги batch.</param>
/// <param name="MaterialIndex">[0x02..0x04] Индекс material slot, резолвится через WEAR/MAT0 pipeline.</param> /// <param name="MaterialIndex">[0x02..0x04] Индекс material slot. Может быть overridden CAniMesh::forced_material_index.</param>
/// <param name="Opaque4">[0x04..0x06] Opaque поле. Старое локальное имя: TriangleCount; не подтверждено.</param> /// <param name="Opaque04">[0x04..0x06] Opaque. В GetBatchRenderData попадает в packed field вместе с material/lightmap данными.</param>
/// <param name="Opaque6">[0x06..0x08] Opaque поле. Сохранять побайтно в writer.</param> /// <param name="LocalBatchIndex">[0x06..0x08] Локальный batch index. В GetBatchRenderData складывается с MSH_piece.local_index_base / batch base.</param>
/// <param name="IndexCount">[0x08..0x0A] Количество индексов в индексном буфере 0x06.</param> /// <param name="IndexCount">[0x08..0x0A] Количество индексов из component 0x06.</param>
/// <param name="IndexStart">[0x0A..0x0E] Первый индекс в индексном буфере 0x06.</param> /// <param name="IndexStart">[0x0A..0x0E] Первый индекс в component 0x06.</param>
/// <param name="Opaque14">[0x0E..0x10] Opaque поле. Старое локальное имя: CountOf03; не подтверждено.</param> /// <param name="VertexCount">[0x0E..0x10] Количество вершин для render primitive.</param>
/// <param name="BaseVertex">[0x10..0x14] Базовая вершина в position stream 0x03. У FParkan: baseVertex.</param> /// <param name="BaseVertex">[0x10..0x14] Base vertex в vertex streams, включая position stream 0x03.</param>
public readonly record struct Batch( public readonly record struct Batch(
BatchFlags BatchFlags, BatchFlags Flags,
ushort MaterialIndex, ushort MaterialIndex,
ushort Opaque4, ushort Opaque04,
ushort Opaque6, ushort LocalBatchIndex,
ushort IndexCount, ushort IndexCount,
uint IndexStart, uint IndexStart,
ushort Opaque14, ushort VertexCount,
uint BaseVertex); uint BaseVertex);
} }
[Flags]
public enum BatchFlags : ushort public enum BatchFlags : ushort
{ {
MSH0D_BATCH_SPECIAL_SUBMIT = 1, None = 0,
MSH0D_BATCH_INTERSECT_TWO_SIDED = 2,
MSH0D_BATCH_SPECIAL_PASS_OR_CONDITIONAL = 5, /// <summary>
MSH0D_BATCH_USE_MATERIAL_PHASE = 8, /// Special/immediate submit path in CShade::SubmitMeshPieceBatches.
MSH0D_BATCH_SUPPRESS_BATCH = 32, /// </summary>
MSH0D_BATCH_FORCE_PASS2 = 256, SpecialSubmit = 0x0001,
MSH0D_BATCH_EMULATE_POINT_LIGHTS = 2048,
MSH0D_BATCH_HAS_LIGHTMAP_OR_TEXCOORD1 = 8192, /// <summary>
MSH0D_BATCH_FACING_TEST_EARLY_OUT = 16384, /// 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,
} }
+223 -240
View File
@@ -7,325 +7,308 @@ namespace ParkanPlayground;
public enum MshType public enum MshType
{ {
Unknown, Unknown,
Model, // Has component 06 (indices), 0D (batches), 07 /// <summary>
Landscape // Has component 0B (per-triangle material), uses 15 directly /// Для обычной модели минимальный геометрический путь сейчас выглядит так:
/// 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 void Convert(string mshPath, string? outputPath = null, int lod = 0, int group = 0)
public static MshType DetectMeshType(NResArchive archive)
{ {
bool hasComponent06 = archive.Files.Any(f => f.FileType == "06 00 00 00"); var result = NResParser.ReadFile(mshPath);
bool hasComponent0B = archive.Files.Any(f => f.FileType == "0B 00 00 00"); if (result.Archive is null)
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 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; return;
} }
var archive = mshNresResult.Archive; var archive = result.Archive;
var meshType = DetectMeshType(archive); var meshType = DetectMeshType(archive);
outputPath ??= Path.ChangeExtension(mshPath, ".obj"); outputPath ??= Path.ChangeExtension(mshPath, ".obj");
Console.WriteLine($"Converting: {Path.GetFileName(mshPath)}"); Console.WriteLine($"Converting: {Path.GetFileName(mshPath)}");
Console.WriteLine($"Detected type: {meshType}"); Console.WriteLine($"Detected type: {meshType}");
Console.WriteLine($"LOD: {lod}, group: {group}");
using var fs = new FileStream(mshPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var fs = new FileStream(mshPath, FileMode.Open, FileAccess.Read, FileShare.Read);
switch (meshType) switch (meshType)
{ {
case MshType.Model: case MshType.Model:
ConvertModel(fs, archive, outputPath, lodLevel, group); ConvertModel(fs, archive, outputPath, lod, group);
break; break;
case MshType.Landscape: case MshType.Landscape:
ConvertLandscape(fs, archive, outputPath, lodLevel, group); ConvertLandscape(fs, archive, outputPath, lod, group);
break; break;
default: default:
Console.WriteLine("ERROR: Unknown mesh type, cannot convert."); Console.WriteLine("ERROR: Unknown or unsupported MSH type.");
break; break;
} }
} }
/// <summary> public static MshType DetectMeshType(NResArchive archive)
/// Конвертирует обычную модель в OBJ.
/// Путь данных: 0x01 -> 0x02 -> 0x0D -> 0x06 -> 0x03.
/// </summary>
private void ConvertModel(FileStream fs, NResArchive archive, string outputPath, int lodLevel, int group)
{ {
var component01 = Msh0x01.ReadComponent(fs, archive); var has03 = HasComponent(archive, "03");
var component02 = Msh0x02.ReadComponent(fs, archive); var has06 = HasComponent(archive, "06");
var component03 = Msh0x03.ReadComponent(fs, archive); var has0D = HasComponent(archive, "0D");
var component06 = Msh0x06.ReadComponent(fs, archive); var has15 = HasComponent(archive, "15");
var component07 = Msh0x07.ReadComponent(fs, archive);
var component0D = Msh0x0D.ReadComponent(fs, archive);
Console.WriteLine($"Vertices: {component03.Count}"); if (has03 && has06 && has0D)
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)
{ {
sw.WriteLine(FormattableString.Invariant($"v {v.X:F6} {v.Y:F6} {v.Z:F6}")); return MshType.Model;
} }
int exportedFaces = 0; if (has03 && has15 && !has06)
for (var pieceIndex = 0; pieceIndex < component01.Elements.Count; pieceIndex++)
{ {
var piece = component01.Elements[pieceIndex]; return MshType.Landscape;
}
var submeshIdx = piece.ResolveSlotIndex(lodLevel, group);
if (submeshIdx == 0xFFFF || submeshIdx >= component02.Elements.Count)
continue;
sw.WriteLine($"g piece_{pieceIndex}"); return MshType.Unknown;
}
var submesh = component02.Elements[submeshIdx];
var batchStart = submesh.BatchStart; private static bool HasComponent(NResArchive archive, string hexType)
var batchCount = submesh.BatchCount; {
if (batchStart + batchCount > component0D.Count) 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; continue;
} }
for (var batchIdx = 0; batchIdx < batchCount; batchIdx++) if (slotIndex >= geometry.Slots.Count)
{ {
var batch = component0D[batchStart + batchIdx]; Warn($"Piece {pieceIndex}: geometry slot {slotIndex} out of range");
var baseVertex = (int)batch.BaseVertex; skippedSlots++;
var indexStart = (int)batch.IndexStart; continue;
var indexCount = batch.IndexCount; }
if (indexStart + indexCount > component06.Count)
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; continue;
} }
for (int i = 0; i < indexCount; i += 3) writer.WriteLine($"# batch {batchIndex}, material {batch.MaterialIndex}, flags {batch.Flags}");
{
if (i + 2 >= indexCount)
{
Console.WriteLine($"WARNING: Batch has non-triangle index tail in piece {pieceIndex}");
break;
}
var i1 = baseVertex + component06[indexStart + i]; for (var i = 0; i + 2 < batch.IndexCount; i += 3)
var i2 = baseVertex + component06[indexStart + i + 1]; {
var i3 = baseVertex + component06[indexStart + i + 2]; var indexBase = (int)batch.IndexStart + i;
if (i1 >= component03.Count || i2 >= component03.Count || i3 >= component03.Count)
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; continue;
} }
sw.WriteLine($"f {i1 + 1} {i2 + 1} {i3 + 1}"); WriteFace(writer, v1, v2, v3);
exportedFaces++; 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}"); Console.WriteLine($"Output: {outputPath}");
} }
/// <summary> private static void ConvertLandscape(
/// Конвертирует terrain mesh в OBJ. FileStream fs,
/// Путь данных является terrain-гипотезой проекта: 0x01 -> 0x02 -> 0x15. NResArchive archive,
/// </summary> string outputPath,
private void ConvertLandscape(FileStream fs, NResArchive archive, string outputPath, int lodLevel, int group) int lod,
int group)
{ {
var component01 = Msh0x01.ReadComponent(fs, archive); var nodes = Msh0x01.ReadComponent(fs, archive);
var component02 = Msh0x02.ReadComponent(fs, archive); var geometry = Msh0x02.ReadComponent(fs, archive);
var component03 = Msh0x03.ReadComponent(fs, archive); var vertices = Msh0x03.ReadComponent(fs, archive);
var component15 = Msh0x15.ReadComponent(fs, archive); var triangles = Msh0x15.ReadComponent(fs, archive);
Console.WriteLine($"Vertices: {component03.Count}"); using var writer = CreateObjWriter(outputPath);
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)}");
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; if (slotIndex == ushort.MaxValue)
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++)
{ {
var triIdx = triangleStart + triOffset; skippedSlots++;
if (triIdx >= component15.Count) 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; continue;
} }
var tri = component15[triIdx]; var tri = triangles[triIndex];
if (tri.Vertex1Index >= component03.Count || tri.Vertex2Index >= component03.Count || tri.Vertex3Index >= component03.Count)
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; continue;
} }
sw.WriteLine($"f {tri.Vertex1Index + 1} {tri.Vertex2Index + 1} {tri.Vertex3Index + 1}"); WriteFace(writer, v1, v2, v3);
exportedFaces++; 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}"); Console.WriteLine($"Output: {outputPath}");
} }
public record Face(Vector3 P1, Vector3 P2, Vector3 P3); private static StreamWriter CreateObjWriter(string outputPath)
public static void ExportCube(string filePath, Vector3[] points)
{ {
if (points.Length != 8) return new StreamWriter(outputPath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
throw new ArgumentException("Cube must have exactly 8 points."); }
using (StreamWriter writer = new StreamWriter(filePath)) private static void WriteVertices(StreamWriter writer, IReadOnlyList<Vector3> vertices)
{
foreach (var vertex in vertices)
{ {
// Запись вершин. writer.WriteLine(FormattableString.Invariant($"v {vertex.X:F6} {vertex.Y:F6} {vertex.Z:F6}"));
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]}");
}
} }
} }
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; writer.WriteLine($"f {v1 + 1} {v2 + 1} {v3 + 1}");
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;
}
}
} }
void Export(string filePath, IEnumerable<Vector3> vertices, List<IndexedEdge> edges) private static bool IsValidTriangle(int vertexCount, int v1, int v2, int v3)
{ {
using (var writer = new StreamWriter(filePath)) return IsValidVertexIndex(vertexCount, v1)
{ && IsValidVertexIndex(vertexCount, v2)
writer.WriteLine("# Exported OBJ file"); && IsValidVertexIndex(vertexCount, v3);
// Запись вершин.
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}");
}
}
} }
}
private static bool IsValidVertexIndex(int vertexCount, int index)
{
return index >= 0 && index < vertexCount;
}
private static void Warn(string message)
{
Console.WriteLine($"WARNING: {message}");
}
}
+2 -2
View File
@@ -392,8 +392,8 @@ color
- `0x22` - unknown (implement by CLandscape) - `0x22` - unknown (implement by CLandscape)
- `0x23` - IGameSettingsRoot - `0x23` - IGameSettingsRoot
- `0x24` - IGameObject2 - `0x24` - IGameObject2
- `0x25` - unknown (implemented by CAniMesh) - `0x25` - ICollisionMesh (придумал сам implemented by CAniMesh)
- `0x26` - unknown (implemented by CAniMesh and CControl and CWizard) - `0x26` - IScalable (придумал сам implemented by CAniMesh and CControl and CWizard)
- `0x28` - ICollObject - `0x28` - ICollObject
- `0x29` - IPhysicalModel - `0x29` - IPhysicalModel
- `0x101` - I3DRender - `0x101` - I3DRender