mirror of
https://github.com/sampletext32/ParkanPlayground.git
synced 2026-08-15 02:57:49 +04:00
refactor into projects
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
using System.Buffers.Binary;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x01: таблица узлов модели.
|
||||
/// Для обычного AniMesh node имеет size 0x26.
|
||||
/// У ландшафта metadata/magic1 может иметь другой смысл, например grid_x_count.
|
||||
/// </summary>
|
||||
public static class Msh0x01
|
||||
{
|
||||
public const int NormalElementSize = 0x26;
|
||||
public const int StateCount = 3;
|
||||
public const int MaxLodCount = 5;
|
||||
public const int SlotCount = StateCount * MaxLodCount;
|
||||
|
||||
public static Msh0x01Component ReadComponent(FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "01 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain node table component (0x01)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize <= 0)
|
||||
{
|
||||
throw new Exception("Node table component (0x01) has invalid element size");
|
||||
}
|
||||
|
||||
if (entry.FileLength % entry.ElementSize != 0)
|
||||
{
|
||||
throw new Exception("Node table component (0x01) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
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 nodes = new List<Node>(elementCount);
|
||||
|
||||
for (var i = 0; i < elementCount; i++)
|
||||
{
|
||||
var baseOffset = i * entry.ElementSize;
|
||||
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++)
|
||||
{
|
||||
slotIndices[slotIndex] =
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(elementSpan.Slice(0x08 + slotIndex * 2, 2));
|
||||
}
|
||||
|
||||
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)),
|
||||
Msh02SlotIndicesByStateAndLOD: slotIndices));
|
||||
}
|
||||
|
||||
return new Msh0x01Component(entry.ElementSize, nodes);
|
||||
}
|
||||
|
||||
/// <summary>Результат чтения MSH-компонента 0x01.</summary>
|
||||
/// <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>Узел 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="Msh02SlotIndicesByStateAndLOD">
|
||||
/// [0x08..0x26] Индексы geometry slot в MSH 0x02.
|
||||
/// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует.
|
||||
/// </param>
|
||||
public sealed record Node(
|
||||
byte[] RawBytes,
|
||||
NodeFlags Flags,
|
||||
ushort ParentIndexOrLink,
|
||||
ushort AnimMapStart0x13,
|
||||
ushort FallbackKey0x08,
|
||||
ushort[] Msh02SlotIndicesByStateAndLOD)
|
||||
{
|
||||
public bool IsRoot => ParentIndexOrLink == ushort.MaxValue;
|
||||
|
||||
public int ParentIndexOrMinusOne =>
|
||||
ParentIndexOrLink == ushort.MaxValue ? -1 : ParentIndexOrLink;
|
||||
|
||||
public ushort ResolveSlotIndex(int state, int lod = 0)
|
||||
{
|
||||
|
||||
// MODEL_STATE_DEFAULT -1
|
||||
// MODEL_STATE_REGULAR 0
|
||||
// MODEL_STATE_COLLAPSED 1
|
||||
// _MODEL_STATE_UNKNOWN_2 2
|
||||
|
||||
// LOD_LEVEL_MAX_0 0
|
||||
// LOD_LEVEL_MINUS_1 1
|
||||
// LOD_LEVEL_MINUS_2 2
|
||||
// LOD_LEVEL_MINUS_3 3
|
||||
// LOD_LEVEL_MINUS_4 4
|
||||
|
||||
var index = state * MaxLodCount + lod;
|
||||
|
||||
return index >= 0 && index < Msh02SlotIndicesByStateAndLOD.Length
|
||||
? Msh02SlotIndicesByStateAndLOD[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 < StateCount; lod++)
|
||||
{
|
||||
if (!HasGeometrySlot(lod, group))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum NodeFlags : ushort
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Still uncertain. In recursive bounds/intersection paths this can suppress/alter child recursion.
|
||||
/// Seen as child_node.flags & 0x04.
|
||||
/// </summary>
|
||||
MSH01_BOUNDS_MODE0_STOP = 0x0004,
|
||||
|
||||
/// <summary>
|
||||
/// Stops recursive traversal into children for bounds/render/intersection helpers.
|
||||
/// </summary>
|
||||
MSH01_STOP_CHILD_BOUNDS_TRAVERSAL = 0x0010,
|
||||
|
||||
/// <summary>
|
||||
/// Special lod-4 mode bit. In lod 4, selects alternate piece render mode.
|
||||
/// </summary>
|
||||
MSH01_HAS_SPECIAL_LOD_4 = 0x0020,
|
||||
|
||||
/// <summary>
|
||||
/// Exclude from shadow / no shadow. CAniMesh tracks has_any_shadow_casting_piece when this bit is absent.
|
||||
/// </summary>
|
||||
MSH01_NO_SHADOW = 0x0040,
|
||||
|
||||
/// <summary>
|
||||
/// Used during attached MSH load: if parent/root description contains "central", piece gets hidden/excluded flag.
|
||||
/// Exact semantic name still provisional.
|
||||
/// </summary>
|
||||
MSH01_CHECK_PARENT_DESCRIPTION_CENTRAL = 0x0800,
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Buffers.Binary;
|
||||
using Common;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// 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 entry = archive.Files.FirstOrDefault(x => x.FileType == "02 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain geometry slot component (0x02)");
|
||||
}
|
||||
|
||||
if (entry.FileLength < HeaderSize)
|
||||
{
|
||||
throw new Exception("Geometry slot component (0x02) is smaller than the 0x8C-byte header");
|
||||
}
|
||||
|
||||
if ((entry.FileLength - HeaderSize) % SlotSize != 0)
|
||||
{
|
||||
throw new Exception("Geometry slot component (0x02) payload after header is not divisible by 0x44");
|
||||
}
|
||||
|
||||
var data = new byte[entry.FileLength];
|
||||
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0, data.Length);
|
||||
|
||||
var span = data.AsSpan();
|
||||
|
||||
var header = ReadHeader(span.Slice(0, HeaderSize));
|
||||
|
||||
var slotBytes = span.Slice(HeaderSize);
|
||||
var slotCount = slotBytes.Length / SlotSize;
|
||||
var slots = new List<GeometrySlot>(slotCount);
|
||||
|
||||
for (var i = 0; i < slotCount; i++)
|
||||
{
|
||||
var slot = slotBytes.Slice(i * SlotSize, SlotSize);
|
||||
|
||||
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)
|
||||
{
|
||||
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),
|
||||
MeshStart: ReadVector3(header, 0x70),
|
||||
MeshEnd: 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>
|
||||
/// <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>Заголовок MSH 0x02, length = 0x8C.</summary>
|
||||
/// <param name="BoundingBox">[0x00..0x60] Bounding box из 8 точек.</param>
|
||||
/// <param name="BoundingSphere">[0x60..0x70] Bounding sphere: xyz = center, w = radius.</param>
|
||||
/// <param name="MeshStart">[0x70..0x7C] Нижняя/опорная точка меша. Минимальная точка меша ??</param>
|
||||
/// <param name="MeshEnd">[0x7C..0x88] Верхняя точка меша. Максимальная точка меша ??</param>
|
||||
/// <param name="XyRadius">[0x88..0x8C] Радиус/extent в плоскости XY.</param>
|
||||
public sealed record Msh02Header(
|
||||
BoundingBox BoundingBox,
|
||||
Sphere BoundingSphere,
|
||||
Vector3 MeshStart,
|
||||
Vector3 MeshEnd,
|
||||
float XyRadius);
|
||||
|
||||
/// <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 Opaque38,
|
||||
uint Opaque3C,
|
||||
uint Opaque40)
|
||||
{
|
||||
public int BatchEndExclusive0x0D => BatchStart0x0D + BatchCount0x0D;
|
||||
|
||||
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>
|
||||
/// <param name="BottomBackLeft">[0x24..0x30] Нижняя задняя левая точка.</param>
|
||||
/// <param name="TopFrontLeft">[0x30..0x3C] Верхняя передняя левая точка.</param>
|
||||
/// <param name="TopFrontRight">[0x3C..0x48] Верхняя передняя правая точка.</param>
|
||||
/// <param name="TopBackRight">[0x48..0x54] Верхняя задняя правая точка.</param>
|
||||
/// <param name="TopBackLeft">[0x54..0x60] Верхняя задняя левая точка.</param>
|
||||
public sealed record BoundingBox(
|
||||
Vector3 BottomFrontLeft,
|
||||
Vector3 BottomFrontRight,
|
||||
Vector3 BottomBackRight,
|
||||
Vector3 BottomBackLeft,
|
||||
Vector3 TopFrontLeft,
|
||||
Vector3 TopFrontRight,
|
||||
Vector3 TopBackRight,
|
||||
Vector3 TopBackLeft);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Buffers.Binary;
|
||||
using Common;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x03: позиции вершин
|
||||
/// </summary>
|
||||
public class Msh0x03
|
||||
{
|
||||
public static List<Vector3> ReadComponent(FileStream mshFs, NResArchive mshNres)
|
||||
{
|
||||
var verticesFileEntry = mshNres.Files.FirstOrDefault(x => x.FileType == "03 00 00 00");
|
||||
|
||||
if (verticesFileEntry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain vertices file (03)");
|
||||
}
|
||||
|
||||
if (verticesFileEntry.ElementSize != 12)
|
||||
{
|
||||
throw new Exception("Vertices file (03) element size is not 12");
|
||||
}
|
||||
|
||||
if (verticesFileEntry.FileLength % verticesFileEntry.ElementSize != 0)
|
||||
{
|
||||
throw new Exception("Positions component (0x03) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
var verticesFile = new byte[verticesFileEntry.FileLength];
|
||||
mshFs.Seek(verticesFileEntry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(verticesFile, 0, verticesFile.Length);
|
||||
|
||||
var vertices = verticesFile.Chunk(12).Select(x => new Vector3(
|
||||
BinaryPrimitives.ReadSingleLittleEndian(x.AsSpan(0)),
|
||||
BinaryPrimitives.ReadSingleLittleEndian(x.AsSpan(4)),
|
||||
BinaryPrimitives.ReadSingleLittleEndian(x.AsSpan(8))
|
||||
)
|
||||
).ToList();
|
||||
return vertices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x04: упакованные нормали вершин. clamp(component / 127.0, -1..1).
|
||||
/// </summary>
|
||||
public static class Msh0x04
|
||||
{
|
||||
public static List<Msh04Normal> ReadComponent(FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "04 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain file (04)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != 4)
|
||||
{
|
||||
throw new Exception("Packed normals file (04) element size is not 4");
|
||||
}
|
||||
|
||||
if (entry.FileLength % entry.ElementSize != 0)
|
||||
{
|
||||
throw new Exception("Packed normals component (0x04) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
var data = new byte[entry.FileLength];
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0, data.Length);
|
||||
|
||||
var elements = new List<Msh04Normal>(entry.FileLength / entry.ElementSize);
|
||||
for (var i = 0; i < entry.FileLength / entry.ElementSize; i++)
|
||||
{
|
||||
var offset = i * 4;
|
||||
elements.Add(new Msh04Normal(
|
||||
unchecked((sbyte)data[offset + 0]),
|
||||
unchecked((sbyte)data[offset + 1]),
|
||||
unchecked((sbyte)data[offset + 2]),
|
||||
unchecked((sbyte)data[offset + 3])));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Упакованная нормаль: четыре int8-компоненты (length = 4).</summary>
|
||||
/// <param name="X">[0x00..0x01] X-компонента.</param>
|
||||
/// <param name="Y">[0x01..0x02] Y-компонента.</param>
|
||||
/// <param name="Z">[0x02..0x03] Z-компонента.</param>
|
||||
/// <param name="W">[0x03..0x04] W-компонента.</param>
|
||||
public readonly record struct Msh04Normal(sbyte X, sbyte Y, sbyte Z, sbyte W)
|
||||
;
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Buffers.Binary;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x05: упакованные UV0 (TEXCOORD0). component / 1024.0.
|
||||
/// </summary>
|
||||
public static class Msh0x05
|
||||
{
|
||||
public static List<Msh05Uv> ReadComponent(FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "05 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain file (05)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != 4)
|
||||
{
|
||||
throw new Exception("Packed UV file (05) element size is not 4");
|
||||
}
|
||||
|
||||
if (entry.FileLength % entry.ElementSize != 0)
|
||||
{
|
||||
throw new Exception("Packed UV component (0x05) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
var data = new byte[entry.FileLength];
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0, data.Length);
|
||||
|
||||
var elements = new List<Msh05Uv>(entry.FileLength / entry.ElementSize);
|
||||
for (var i = 0; i < entry.FileLength / entry.ElementSize; i++)
|
||||
{
|
||||
var span = data.AsSpan(i * 4, 4);
|
||||
elements.Add(new Msh05Uv(
|
||||
BinaryPrimitives.ReadInt16LittleEndian(span[0..2]),
|
||||
BinaryPrimitives.ReadInt16LittleEndian(span[2..4])));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Упакованные UV0: две int16-компоненты (length = 4).</summary>
|
||||
/// <param name="U">[0x00..0x02] U-компонента, uv = U / 1024.0.</param>
|
||||
/// <param name="V">[0x02..0x04] V-компонента, uv = V / 1024.0.</param>
|
||||
public readonly record struct Msh05Uv(short U, short V);
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Buffers.Binary;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x06: индексный буфер.
|
||||
/// Используется batch-ами 0x0D через Batch.IndexStart / Batch.IndexCount.
|
||||
/// Индексы являются ushort и обычно читаются тройками как triangle indices.
|
||||
/// </summary>
|
||||
public static class Msh0x06
|
||||
{
|
||||
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 index buffer component (0x06)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != ElementSize)
|
||||
{
|
||||
throw new Exception("Index buffer component (0x06) element size is not 2");
|
||||
}
|
||||
|
||||
if (entry.FileLength % ElementSize != 0)
|
||||
{
|
||||
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 span = data.AsSpan();
|
||||
var indices = new List<ushort>(entry.FileLength / ElementSize);
|
||||
|
||||
for (var offset = 0; offset < span.Length; offset += ElementSize)
|
||||
{
|
||||
indices.Add(BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, ElementSize)));
|
||||
}
|
||||
|
||||
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="Vertex0">Первый vertex index внутри batch/base vertex range.</param>
|
||||
/// <param name="Vertex1">Второй vertex index внутри batch/base vertex range.</param>
|
||||
/// <param name="Vertex2">Третий vertex index внутри batch/base vertex range.</param>
|
||||
public readonly record struct TriangleIndices(
|
||||
ushort Vertex0,
|
||||
ushort Vertex1,
|
||||
ushort Vertex2
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Buffers.Binary;
|
||||
using Common;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x07: triangle descriptors.
|
||||
/// Используется geometry walker-ами для raycast / point-inside / фильтрации triangle flags.
|
||||
/// Геометрические vertex indices лежат не здесь, а в MSH 0x06.
|
||||
/// </summary>
|
||||
public static class Msh0x07
|
||||
{
|
||||
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 triangle descriptor component (0x07)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != ElementSize)
|
||||
{
|
||||
throw new Exception("Triangle descriptor component (0x07) element size is not 16");
|
||||
}
|
||||
|
||||
if (entry.FileLength % ElementSize != 0)
|
||||
{
|
||||
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 span = data.AsSpan();
|
||||
var descriptors = new List<TriangleDescriptor>(entry.FileLength / ElementSize);
|
||||
|
||||
for (var offset = 0; offset < span.Length; offset += ElementSize)
|
||||
{
|
||||
var element = span.Slice(offset, ElementSize);
|
||||
|
||||
descriptors.Add(new TriangleDescriptor(
|
||||
Flags: (TriangleFlags)BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x00, 2)),
|
||||
LinkedTriangleIndex0_0x07: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x02, 2)),
|
||||
LinkedTriangleIndex1_0x07: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x04, 2)),
|
||||
LinkedTriangleIndex2_0x07: 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)),
|
||||
PackedSelector: BinaryPrimitives.ReadUInt16LittleEndian(element.Slice(0x0E, 2))));
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
/// <summary>Описатель triangle MSH 0x07, length = 0x10.</summary>
|
||||
/// <param name="Flags">[0x00..0x02] Triangle flags. Используются GeometryWalkFilter require/exclude.</param>
|
||||
/// <param name="LinkedTriangleIndex0_0x07">[0x02..0x04] Указывает на треугольники в 0x07 (текущем) компоненте.</param>
|
||||
/// <param name="LinkedTriangleIndex1_0x07">[0x04..0x06] Указывает на треугольники в 0x07 (текущем) компоненте.</param>
|
||||
/// <param name="LinkedTriangleIndex2_0x07">[0x06..0x08] Указывает на треугольники в 0x07 (текущем) компоненте.</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="PackedSelector">[0x0E..0x10] Packed selectors. 3 трактуется как 0xFFFF.</param>
|
||||
public readonly record struct TriangleDescriptor(
|
||||
TriangleFlags Flags,
|
||||
ushort LinkedTriangleIndex0_0x07,
|
||||
ushort LinkedTriangleIndex1_0x07,
|
||||
ushort LinkedTriangleIndex2_0x07,
|
||||
short PackedNormalX,
|
||||
short PackedNormalY,
|
||||
short PackedNormalZ,
|
||||
ushort PackedSelector)
|
||||
{
|
||||
public Vector3 Normal => new(
|
||||
PackedNormalX * PackedNormalScale,
|
||||
PackedNormalY * PackedNormalScale,
|
||||
PackedNormalZ * PackedNormalScale);
|
||||
|
||||
public ushort GetSelector(int index)
|
||||
{
|
||||
if (index is < 0 or > 2)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
|
||||
var selector = (PackedSelector >> (index * 2)) & 0b11;
|
||||
|
||||
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.
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Common;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
public static class Msh0x08
|
||||
{
|
||||
public static List<AnimationDescriptor> ReadComponent(FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "08 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain animation descriptor component (0x08)");
|
||||
}
|
||||
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
|
||||
var descriptors = new List<AnimationDescriptor>();
|
||||
|
||||
for (var i = 0; i < entry.ElementCount; i++)
|
||||
{
|
||||
descriptors.Add(new AnimationDescriptor(
|
||||
new Vector3(mshFs.ReadFloatLittleEndian(),
|
||||
mshFs.ReadFloatLittleEndian(),
|
||||
mshFs.ReadFloatLittleEndian()),
|
||||
mshFs.ReadFloatLittleEndian(),
|
||||
new UShortQuaternion(
|
||||
mshFs.ReadUInt16LittleEndian(),
|
||||
mshFs.ReadUInt16LittleEndian(),
|
||||
mshFs.ReadUInt16LittleEndian(),
|
||||
mshFs.ReadUInt16LittleEndian()
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
public record AnimationDescriptor(
|
||||
Vector3 Position,
|
||||
float Time,
|
||||
UShortQuaternion Rotation
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x0A: строки узлов.
|
||||
/// У FParkan: Res10 / Node strings. Старое локальное имя: ExternalRefs.
|
||||
/// </summary>
|
||||
public class Msh0x0A
|
||||
{
|
||||
public static List<string> ReadComponent(FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var aFileEntry = archive.Files.FirstOrDefault(x => x.FileType == "0A 00 00 00");
|
||||
|
||||
if (aFileEntry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain 0A component");
|
||||
}
|
||||
|
||||
var data = new byte[aFileEntry.FileLength];
|
||||
mshFs.Seek(aFileEntry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0, data.Length);
|
||||
|
||||
int pos = 0;
|
||||
var strings = new List<string>();
|
||||
while (pos < data.Length)
|
||||
{
|
||||
if (pos + 4 > data.Length)
|
||||
{
|
||||
throw new Exception("Node strings component (0x0A) has truncated length prefix");
|
||||
}
|
||||
|
||||
var len = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(pos));
|
||||
if (len < 0 || pos + 4 + len > data.Length)
|
||||
{
|
||||
throw new Exception("Node strings component (0x0A) has invalid string length");
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
pos += 4;
|
||||
strings.Add("");
|
||||
}
|
||||
else
|
||||
{
|
||||
var strBytes = data.AsSpan(pos + 4, len);
|
||||
var str = Encoding.ASCII.GetString(strBytes);
|
||||
strings.Add(str);
|
||||
pos += len + 4;
|
||||
if (pos < data.Length && data[pos] == 0)
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strings.Count != aFileEntry.ElementCount)
|
||||
{
|
||||
throw new Exception("String count mismatch in 0A component");
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Buffers.Binary;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "0D 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain file (0D)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != ElementSize)
|
||||
{
|
||||
throw new Exception("Batch table component (0x0D) element size is not 20");
|
||||
}
|
||||
|
||||
if (entry.FileLength % entry.ElementSize != 0)
|
||||
{
|
||||
throw new Exception("Batch table component (0x0D) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
var data = new byte[entry.FileLength];
|
||||
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0, data.Length);
|
||||
|
||||
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>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="IndexCount0x06">[0x08..0x0A] Количество индексов из component 0x06.</param>
|
||||
/// <param name="IndexStart0x06">[0x0A..0x0E] Первый индекс в component 0x06.</param>
|
||||
/// <param name="VertexCount0x03">[0x0E..0x10] Количество вершин для render primitive.</param>
|
||||
/// <param name="BaseVertex0x03">[0x10..0x14] Base vertex в vertex streams, включая position stream 0x03.</param>
|
||||
public readonly record struct Batch(
|
||||
BatchFlags Flags,
|
||||
ushort MaterialIndex,
|
||||
ushort Opaque04,
|
||||
ushort LocalBatchIndex,
|
||||
ushort IndexCount0x06,
|
||||
uint IndexStart0x06,
|
||||
ushort VertexCount0x03,
|
||||
uint BaseVertex0x03);
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum BatchFlags : ushort
|
||||
{
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x14: таблица локальных directional/probe light entries.
|
||||
/// Используется CAniMesh_IJointMesh::SampleMsh14Lights / AccumulateMsh14LightContributions.
|
||||
/// </summary>
|
||||
public static class Msh0x14
|
||||
{
|
||||
public const int ElementSize = 48;
|
||||
|
||||
public static List<LightProbe> ReadComponent(
|
||||
FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "14 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain file (14)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != ElementSize)
|
||||
{
|
||||
throw new Exception("Light probe component (0x14) element size is not 48");
|
||||
}
|
||||
|
||||
if (entry.FileLength % entry.ElementSize != 0)
|
||||
{
|
||||
throw new Exception("Light probe component (0x14) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
var data = new byte[entry.FileLength];
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0, data.Length);
|
||||
|
||||
var elementBytes = data.Chunk(ElementSize);
|
||||
|
||||
var elements = elementBytes.Select(x => new LightProbe(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x00)),
|
||||
new Vector3(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x04)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x08)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x0C))),
|
||||
new Vector3(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x10)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x14)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x18))),
|
||||
new Vector4(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x1C)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x20)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x24)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x28))),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(x.AsSpan(0x2C)))).ToList();
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/// <summary>Light/probe entry 0x14.</summary>
|
||||
/// <param name="PieceIndex">[0x00..0x04] Индекс MSH_piece, чью world matrix используют для transform position/direction.</param>
|
||||
/// <param name="LocalPosition">[0x04..0x10] Локальная позиция источника/probe относительно PieceIndex.</param>
|
||||
/// <param name="LocalDirection">[0x10..0x1C] Локальное направление, transform direction через matrix piece.</param>
|
||||
/// <param name="Color">[0x1C..0x2C] RGBA/intensity color multiplier. В коде умножается на вычисленный strength.</param>
|
||||
/// <param name="Intensity">[0x2C..0x30] Scalar intensity. В коде участвует как * Intensity * 3.0.</param>
|
||||
public readonly record struct LightProbe(
|
||||
int PieceIndex,
|
||||
Vector3 LocalPosition,
|
||||
Vector3 LocalDirection,
|
||||
Vector4 Color,
|
||||
int Intensity);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Buffers.Binary;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
/// <summary>
|
||||
/// MSH-компонент 0x15: terrain-таблица треугольников
|
||||
/// </summary>
|
||||
public static class Msh0x15
|
||||
{
|
||||
public static List<TerrainTriangle> ReadComponent(
|
||||
FileStream mshFs, NResArchive archive)
|
||||
{
|
||||
var entry = archive.Files.FirstOrDefault(x => x.FileType == "15 00 00 00");
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new Exception("Archive doesn't contain file (15)");
|
||||
}
|
||||
|
||||
if (entry.ElementSize != 0x1C)
|
||||
{
|
||||
throw new Exception("Terrain triangle component (0x15) element size is not 28");
|
||||
}
|
||||
|
||||
if (entry.FileLength % entry.ElementSize != 0x0)
|
||||
{
|
||||
throw new Exception("Terrain triangle component (0x15) payload size is not divisible by element size");
|
||||
}
|
||||
|
||||
var data = new byte[entry.FileLength];
|
||||
mshFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
|
||||
mshFs.ReadExactly(data, 0x0, data.Length);
|
||||
|
||||
var elementBytes = data.Chunk(0x1C);
|
||||
|
||||
var elements = elementBytes.Select(x => new TerrainTriangle(
|
||||
Flags: (TerrainTriangleFlags)BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x0)),
|
||||
MaterialData: BinaryPrimitives.ReadUInt32LittleEndian(x.AsSpan(0x4)),
|
||||
Vertex1Index: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x8)),
|
||||
Vertex2Index: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0xA)),
|
||||
Vertex3Index: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0xC)),
|
||||
Neighbor0: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x0E)),
|
||||
Neighbor1: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x10)),
|
||||
Neighbor2: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x12)),
|
||||
NormalX: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x14)),
|
||||
NormalY: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x16)),
|
||||
NormalZ: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x18)),
|
||||
PackedEdgeOrSelector: BinaryPrimitives.ReadUInt16LittleEndian(x.AsSpan(0x1A))))
|
||||
.ToList();
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/// <summary>Terrain-треугольник 0x15 (length = 0x1C)</summary>
|
||||
/// <param name="Flags">[0x00..0x04] Флаги треугольника для terrain path</param>
|
||||
/// <param name="MaterialData">[0x04..0x08] Данные материала terrain</param>
|
||||
/// <param name="Vertex1Index">[0x08..0x0A] Индекс первой вершины в position stream Msh0x03</param>
|
||||
/// <param name="Vertex2Index">[0x0A..0x0C] Индекс второй вершины в position stream Msh0x03</param>
|
||||
/// <param name="Vertex3Index">[0x0C..0x0E] Индекс третьей вершины в position stream Msh0x03</param>
|
||||
/// <param name="Neighbor0">[0x0E..0x10] Сосед 0</param>
|
||||
/// <param name="Neighbor1">[0x10..0x12] Сосед 1</param>
|
||||
/// <param name="Neighbor2">[0x12..0x14] Сосед 2</param>
|
||||
/// <param name="NormalX">[0x14..0x16] Направление нормали</param>
|
||||
/// <param name="NormalY">[0x16..0x18] Направление нормали</param>
|
||||
/// <param name="NormalZ">[0x18..0x1A] Направление нормали</param>
|
||||
/// <param name="PackedEdgeOrSelector">[0x1A..0x1C] TODO</param>
|
||||
public readonly record struct TerrainTriangle(
|
||||
TerrainTriangleFlags Flags,
|
||||
|
||||
uint MaterialData,
|
||||
ushort Vertex1Index,
|
||||
ushort Vertex2Index,
|
||||
ushort Vertex3Index,
|
||||
|
||||
ushort Neighbor0,
|
||||
ushort Neighbor1,
|
||||
ushort Neighbor2,
|
||||
ushort NormalX,
|
||||
ushort NormalY,
|
||||
ushort NormalZ,
|
||||
ushort PackedEdgeOrSelector);
|
||||
}
|
||||
|
||||
public enum TerrainTriangleFlags : uint
|
||||
{
|
||||
MSH15_FLAG_REFLECTIVE_SURFACE = 0x20000,
|
||||
MSH15_FLAG_HAS_MICROTEXTURE = 0x400,
|
||||
MSH15_FLAG_DISABLE_BACKFACE_TEST = 0x8
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Text;
|
||||
using Common;
|
||||
using NResLib;
|
||||
|
||||
namespace MshLib;
|
||||
|
||||
public enum MshType
|
||||
{
|
||||
Unknown,
|
||||
/// <summary>
|
||||
/// Для обычной модели минимальный геометрический путь сейчас выглядит так:
|
||||
/// 0x01 node
|
||||
/// -> 0x02 geometry slot
|
||||
/// -> 0x0D batch
|
||||
/// -> 0x06 indices
|
||||
/// -> 0x03 positions
|
||||
/// </summary>
|
||||
Model,
|
||||
Landscape
|
||||
}
|
||||
|
||||
public sealed class MshConverter
|
||||
{
|
||||
public void Convert(string mshPath, string? outputPath = null, int lod = 0, int group = 0)
|
||||
{
|
||||
var result = NResParser.ReadFile(mshPath);
|
||||
if (result.Archive is null)
|
||||
{
|
||||
Console.WriteLine($"ERROR: Failed to read NRes archive: {result.Error}");
|
||||
return;
|
||||
}
|
||||
|
||||
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, lod, group);
|
||||
break;
|
||||
|
||||
case MshType.Landscape:
|
||||
ConvertLandscape(fs, archive, outputPath, lod, group);
|
||||
break;
|
||||
|
||||
default:
|
||||
Console.WriteLine("ERROR: Unknown or unsupported MSH type.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static MshType DetectMeshType(NResArchive archive)
|
||||
{
|
||||
var has03 = HasComponent(archive, "03");
|
||||
var has06 = HasComponent(archive, "06");
|
||||
var has0D = HasComponent(archive, "0D");
|
||||
var has15 = HasComponent(archive, "15");
|
||||
|
||||
if (has03 && has06 && has0D)
|
||||
{
|
||||
return MshType.Model;
|
||||
}
|
||||
|
||||
if (has03 && has15 && !has06)
|
||||
{
|
||||
return MshType.Landscape;
|
||||
}
|
||||
|
||||
return MshType.Unknown;
|
||||
}
|
||||
|
||||
private static bool HasComponent(NResArchive archive, string hexType)
|
||||
{
|
||||
return archive.Files.Any(x => x.FileType.Equals($"{hexType} 00 00 00", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ConvertModel(
|
||||
FileStream fs,
|
||||
NResArchive archive,
|
||||
string outputPath,
|
||||
int lod,
|
||||
int group)
|
||||
{
|
||||
var nodes = Msh0x01.ReadComponent(fs, archive);
|
||||
var geometry = Msh0x02.ReadComponent(fs, archive);
|
||||
var vertices = Msh0x03.ReadComponent(fs, archive);
|
||||
var indices = Msh0x06.ReadComponent(fs, archive);
|
||||
var batches = Msh0x0D.ReadComponent(fs, archive);
|
||||
|
||||
using var writer = CreateObjWriter(outputPath);
|
||||
|
||||
writer.WriteLine($"# MSH model converted from {Path.GetFileName(outputPath)}");
|
||||
writer.WriteLine($"# LOD: {lod}, group: {group}");
|
||||
writer.WriteLine($"# Nodes: {nodes.Nodes.Count}");
|
||||
writer.WriteLine($"# Geometry slots: {geometry.Slots.Count}");
|
||||
writer.WriteLine($"# Batches: {batches.Count}");
|
||||
writer.WriteLine($"# Vertices: {vertices.Count}");
|
||||
writer.WriteLine();
|
||||
|
||||
WriteVertices(writer, vertices);
|
||||
|
||||
var exportedFaces = 0;
|
||||
var skippedSlots = 0;
|
||||
var skippedBatches = 0;
|
||||
var skippedFaces = 0;
|
||||
|
||||
for (var pieceIndex = 1; pieceIndex < nodes.Nodes.Count; pieceIndex += 100000)
|
||||
{
|
||||
var node = nodes.Nodes[pieceIndex];
|
||||
|
||||
for (var s = 0; s < node.Msh02SlotIndicesByStateAndLOD.Length; s++)
|
||||
{
|
||||
var slotIndex = node.Msh02SlotIndicesByStateAndLOD[s];
|
||||
|
||||
if (slotIndex == ushort.MaxValue)
|
||||
{
|
||||
skippedSlots++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slotIndex >= geometry.Slots.Count)
|
||||
{
|
||||
Warn($"Piece {pieceIndex}: geometry slot {slotIndex} out of range");
|
||||
skippedSlots++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var slot = geometry.Slots[slotIndex];
|
||||
|
||||
if (!slot.HasBatches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slot.BatchEndExclusive0x0D > batches.Count)
|
||||
{
|
||||
Warn($"Piece {pieceIndex}: batch range {slot.BatchStart0x0D}:{slot.BatchCount0x0D} out of range");
|
||||
skippedBatches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.WriteLine();
|
||||
writer.WriteLine($"o piece_{pieceIndex}_{s}");
|
||||
|
||||
for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++)
|
||||
{
|
||||
var batch = batches[batchIndex];
|
||||
|
||||
if (batch.IndexStart0x06 + batch.IndexCount0x06 > indices.Count)
|
||||
{
|
||||
Warn(
|
||||
$"Piece {pieceIndex}, batch {batchIndex}: index range {batch.IndexStart0x06}:{batch.IndexCount0x06} out of range");
|
||||
skippedBatches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.WriteLine($"# batch {batchIndex}, material {batch.MaterialIndex}, flags {batch.Flags}");
|
||||
|
||||
for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3)
|
||||
{
|
||||
var indexBase = (int)batch.IndexStart0x06 + i;
|
||||
|
||||
var v1 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 0]);
|
||||
var v2 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 1]);
|
||||
var v3 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 2]);
|
||||
|
||||
if (!IsValidTriangle(vertices.Count, v1, v2, v3))
|
||||
{
|
||||
skippedFaces++;
|
||||
continue;
|
||||
}
|
||||
|
||||
WriteFace(writer, v1, v2, v3);
|
||||
exportedFaces++;
|
||||
}
|
||||
|
||||
if (batch.IndexCount0x06 % 3 != 0)
|
||||
{
|
||||
Warn(
|
||||
$"Piece {pieceIndex}, batch {batchIndex}: index count {batch.IndexCount0x06} is not divisible by 3");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Exported: {vertices.Count} vertices, {exportedFaces} faces");
|
||||
Console.WriteLine($"Skipped slots: {skippedSlots}, skipped batches: {skippedBatches}, skipped faces: {skippedFaces}");
|
||||
Console.WriteLine($"Output: {outputPath}");
|
||||
}
|
||||
|
||||
private static void ConvertLandscape(
|
||||
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 triangles = Msh0x15.ReadComponent(fs, archive);
|
||||
|
||||
using var writer = CreateObjWriter(outputPath);
|
||||
|
||||
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++)
|
||||
{
|
||||
var node = nodes.Nodes[tileIndex];
|
||||
var slotIndex = node.ResolveSlotIndex(lod, group);
|
||||
|
||||
if (slotIndex == ushort.MaxValue)
|
||||
{
|
||||
skippedSlots++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slotIndex >= geometry.Slots.Count)
|
||||
{
|
||||
Warn($"Tile {tileIndex}: geometry slot {slotIndex} out of range");
|
||||
skippedSlots++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var slot = geometry.Slots[slotIndex];
|
||||
|
||||
if (!slot.HasTriangles)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.WriteLine();
|
||||
writer.WriteLine($"g tile_{tileIndex}_slot_{slotIndex}");
|
||||
|
||||
for (var triIndex = slot.TriStart0x07; triIndex < slot.TriEndExclusive0x07; triIndex++)
|
||||
{
|
||||
if (triIndex >= triangles.Count)
|
||||
{
|
||||
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: {vertices.Count} vertices, {exportedFaces} faces");
|
||||
Console.WriteLine($"Skipped slots: {skippedSlots}, skipped faces: {skippedFaces}");
|
||||
Console.WriteLine($"Output: {outputPath}");
|
||||
}
|
||||
|
||||
private static StreamWriter CreateObjWriter(string outputPath)
|
||||
{
|
||||
return new StreamWriter(outputPath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
}
|
||||
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFace(StreamWriter writer, int v1, int v2, int v3)
|
||||
{
|
||||
writer.WriteLine($"f {v1 + 1} {v2 + 1} {v3 + 1}");
|
||||
}
|
||||
|
||||
private static bool IsValidTriangle(int vertexCount, int v1, int v2, int v3)
|
||||
{
|
||||
return IsValidVertexIndex(vertexCount, v1)
|
||||
&& IsValidVertexIndex(vertexCount, v2)
|
||||
&& IsValidVertexIndex(vertexCount, v3);
|
||||
}
|
||||
|
||||
private static bool IsValidVertexIndex(int vertexCount, int index)
|
||||
{
|
||||
return index >= 0 && index < vertexCount;
|
||||
}
|
||||
|
||||
private static void Warn(string message)
|
||||
{
|
||||
Console.WriteLine($"WARNING: {message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\NResLib\NResLib.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,101 @@
|
||||
## Из RE
|
||||
|
||||
Допустим, игровой объект — **танк/бот с башней, пушкой и ракетницей**.
|
||||
|
||||
```text
|
||||
CAniMesh of TankObject
|
||||
│
|
||||
├─ load_id = 0: "tank_body.msh"
|
||||
│ source MSH 0x01 nodes:
|
||||
│
|
||||
│ node 0 -> piece[0] "body_root" parent = -1
|
||||
│ node 1 -> piece[1] "left_track" parent = piece[0]
|
||||
│ node 2 -> piece[2] "right_track" parent = piece[0]
|
||||
│ node 3 -> piece[3] "turret_socket" parent = piece[0]
|
||||
│ node 4 -> piece[4] "hatch" parent = piece[3]
|
||||
│
|
||||
│
|
||||
├─ load_id = 1: "turret.msh"
|
||||
│ attach_parent_absolute_piece_index = 3
|
||||
│
|
||||
│ node 0 -> skipped virtual attach root
|
||||
│ node 1 -> piece[5] "turret_base" parent = piece[3]
|
||||
│ node 2 -> piece[6] "turret_rotor" parent = piece[5]
|
||||
│ node 3 -> piece[7] "gun_socket" parent = piece[6]
|
||||
│ node 4 -> piece[8] "rocket_socket" parent = piece[6]
|
||||
│
|
||||
│
|
||||
├─ load_id = 2: "cannon.msh"
|
||||
│ attach_parent_absolute_piece_index = 7
|
||||
│
|
||||
│ node 0 -> skipped virtual attach root
|
||||
│ node 1 -> piece[9] "cannon_body" parent = piece[7]
|
||||
│ node 2 -> piece[10] "cannon_barrel" parent = piece[9]
|
||||
│ node 3 -> piece[11] "muzzle" parent = piece[10]
|
||||
│
|
||||
│
|
||||
└─ load_id = 3: "rocketlauncher.msh"
|
||||
attach_parent_absolute_piece_index = 8
|
||||
|
||||
node 0 -> skipped virtual attach root
|
||||
node 1 -> piece[12] "launcher_body" parent = piece[8]
|
||||
node 2 -> piece[13] "left_rocket" parent = piece[12]
|
||||
node 3 -> piece[14] "right_rocket" parent = piece[12]
|
||||
```
|
||||
|
||||
Итоговая иерархия `pieces_vector` выглядит уже как одно дерево:
|
||||
|
||||
```text
|
||||
piece[0] body_root load_id = 0
|
||||
├─ piece[1] left_track load_id = 0
|
||||
├─ piece[2] right_track load_id = 0
|
||||
└─ piece[3] turret_socket load_id = 0
|
||||
├─ piece[4] hatch load_id = 0
|
||||
└─ piece[5] turret_base load_id = 1
|
||||
└─ piece[6] turret_rotor load_id = 1
|
||||
├─ piece[7] gun_socket load_id = 1
|
||||
│ └─ piece[9] cannon_body load_id = 2
|
||||
│ └─ piece[10] cannon_barrel
|
||||
│ └─ piece[11] muzzle
|
||||
│
|
||||
└─ piece[8] rocket_socket load_id = 1
|
||||
└─ piece[12] launcher_body load_id = 3
|
||||
├─ piece[13] left_rocket
|
||||
└─ piece[14] right_rocket
|
||||
```
|
||||
|
||||
Ключевой момент:
|
||||
|
||||
```text
|
||||
load_id группирует pieces по исходному .msh,
|
||||
а parent_piece_index строит единую иерархию внутри CAniMesh.
|
||||
```
|
||||
|
||||
То есть после всех загрузок движок уже не обязан думать “это отдельная модель башни, это отдельная модель пушки”. Для поз, рендера и обхода геометрии это просто один `CAniMesh` с одним плоским массивом pieces и parent-связями между ними.
|
||||
|
||||
## Вывод
|
||||
|
||||
По сути получается, что .msh это набор деталей.
|
||||
CAniMesh всегда имеет 1 root модель и может иметь "приклееные" детали.
|
||||
При этом он самостоятельно выполняет перепривязку "приклееных" деталей.
|
||||
Например, если "приклеиваемая" модель имеет 2 детали с каким-то parent, то
|
||||
CAniMesh сдвинет их parent так, чтобы указывать в нужное место.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# CAniMesh / MSH Loading and Joint Bounds — Key Summary
|
||||
|
||||
## New findings discovered in this chat
|
||||
|
||||
* `tag` is best understood as a runtime **loaded submodel tag** for pieces created from one `.msh` load call.
|
||||
* `tag == 0` is the primary/root `.msh` model.
|
||||
* `tag != 0` means an attached/additional `.msh` whose source node `0` is skipped and used as a virtual attach root.
|
||||
* One `CAniMesh` can aggregate multiple `.msh` resources into a single flat `pieces_vector`.
|
||||
* Attached `.msh` pieces are not kept as separate models at runtime; their nodes are remapped into absolute `CAniMesh::pieces_vector` indices.
|
||||
* `attach_parent_absolute_piece_index` is an absolute index in `CAniMesh::pieces_vector`, not a local index inside the attached `.msh`.
|
||||
* The first runtime piece created by each `.msh` load should be marked as a submodel/subtree root.
|
||||
* `MSH_PIECE_FLAG_TAG_ROOT` should be renamed to `MSH_PIECE_FLAG_SUBMODEL_ROOT` or `MSH_PIECE_FLAG_LOADED_SUBTREE_ROOT`.
|
||||
* `ComputeJointBoundingBox` is the authoritative recursive joint/subtree bounds function.
|
||||
* `ComputeJointBoundingSphere` uses specialized fast paths for single-piece and root whole-mesh bounds, but for non-root subtree bounds it delegates to `ComputeJointBoundingBox` and wraps the resulting AABB in a sphere.
|
||||
* Geometry-less pieces are valid helper/socket joints: their bounds become a point or zero-radius sphere at the joint transform origin.
|
||||
* The renamed filter `g_mesh_filter_only_subtree_and_exclude_default_bounds` clarifies that cached “default filter” bounds are really cached bounds for a specific reduced subtree filter.
|
||||
|
||||
---
|
||||
|
||||
## Key function: `CAniMesh::AppendMshResourcePieces (AniMesh.dll/sub_1000ac70)`
|
||||
|
||||
```
|
||||
typedef struct GmsgAppendResourcePayload_CAniMesh {
|
||||
char archive_name[32];
|
||||
char msh_archive_entry_name[32];
|
||||
uint msh_tag;
|
||||
uint attach_parent_absolute_piece_index;
|
||||
uint material_id_hi;
|
||||
} GmsgAppendResourcePayload_CAniMesh;
|
||||
```
|
||||
|
||||
Core behavior:
|
||||
|
||||
```text
|
||||
One call loads one .msh resource.
|
||||
All pieces created by that call receive the same tag.
|
||||
The created pieces are appended to CAniMesh::pieces_vector.
|
||||
```
|
||||
|
||||
For `tag == 0`:
|
||||
|
||||
```text
|
||||
source MSH 0x01 node 0 -> piece[0]
|
||||
source MSH 0x01 node 1 -> piece[1]
|
||||
source MSH 0x01 node 2 -> piece[2]
|
||||
...
|
||||
```
|
||||
|
||||
For `tag != 0`:
|
||||
|
||||
```text
|
||||
source MSH 0x01 node 0 is skipped
|
||||
source MSH 0x01 node 1 -> first newly created piece
|
||||
source MSH 0x01 node 2 -> next newly created piece
|
||||
...
|
||||
```
|
||||
|
||||
Attached parent remap rule:
|
||||
|
||||
```text
|
||||
source parent == 0
|
||||
-> attach_parent_absolute_piece_index
|
||||
|
||||
source parent > 0
|
||||
-> first_new_piece_index + (source_parent - 1)
|
||||
|
||||
source parent == 0xFFFF
|
||||
-> -1 / no parent
|
||||
```
|
||||
|
||||
This means internal parent hierarchy inside the attached `.msh` is preserved, but all indices are converted to absolute `pieces_vector` indices.
|
||||
|
||||
---
|
||||
|
||||
Runtime piece flag:
|
||||
|
||||
```c
|
||||
#define MSH_PIECE_FLAG_SUBMODEL_ROOT 0x01000000
|
||||
```
|
||||
|
||||
Meaning:
|
||||
|
||||
```text
|
||||
Set on the first runtime piece created by one .msh load call.
|
||||
For tag == 0, this is the main model root.
|
||||
For tag != 0, this is the attached submodel/subtree root.
|
||||
```
|
||||
|
||||
### This function creates a runtime representation of a .msh 0x01 piece
|
||||
|
||||
```
|
||||
typedef struct MSH_piece {
|
||||
uint msh_tag_0x00;
|
||||
int local_parent_index_base;
|
||||
uint msh0x01_node_index;
|
||||
undefined4 field_12;
|
||||
undefined4 material_id;
|
||||
EMshPieceFlags flags;
|
||||
int parent_piece_index;
|
||||
EMeshPieceState state;
|
||||
Matrix4x4 world_pose_matrix;
|
||||
Matrix4x4 mesh_space_pose_matrix;
|
||||
Matrix4x4 local_pose_matrix;
|
||||
Quaternion orientation_blend_start_quat;
|
||||
Quaternion orientation_blend_end_quat;
|
||||
float anim1_time_start;
|
||||
float anim1_time_target;
|
||||
float anim2_time_start;
|
||||
float anim2_time_target;
|
||||
bool is_pose_cache_valid;
|
||||
bool exclude_from_pose_update_order;
|
||||
bool uses_local_anim_blend;
|
||||
undefined1 has_orientation_blend;
|
||||
float local_anim_transition_progress;
|
||||
float local_anim_blend_factor;
|
||||
float cached_anim1_sample_time;
|
||||
float cached_anim2_sample_time;
|
||||
float render_phase_0x124??;
|
||||
undefined4 material_phase_0x128??;
|
||||
MSH_Reader * msh_reader;
|
||||
} MSH_piece;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key type: `MSH_0x01_node`
|
||||
|
||||
`MSH` component `0x01` is a source node / piece table.
|
||||
|
||||
Important fields:
|
||||
|
||||
```c
|
||||
typedef struct MSH_0x01_node {
|
||||
uint16_t flags;
|
||||
uint16_t parent_index_or_link;
|
||||
uint16_t anim_map_start_0x13;
|
||||
uint16_t fallback_key_0x08;
|
||||
uint16_t msh02_slot_indices_by_state_and_lod[3][5];
|
||||
} MSH_0x01_node;
|
||||
```
|
||||
|
||||
Meaning:
|
||||
|
||||
```text
|
||||
MSH 0x01 node
|
||||
-> becomes an MSH_piece at runtime
|
||||
-> has parent_index_or_link
|
||||
-> has MSH01 flags
|
||||
-> maps LOD/state to MSH 0x02 geometry slots
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key type: `MSH_02_geometry_slot`
|
||||
|
||||
`MSH` component `0x02` stores geometry slot metadata and local bounds.
|
||||
|
||||
Preferred structure:
|
||||
|
||||
```c
|
||||
typedef struct MSH_02_geometry_slot {
|
||||
uint16_t tri_start_0x07;
|
||||
uint16_t tri_count_0x07;
|
||||
uint16_t batch_start_0x0d;
|
||||
uint16_t batch_count_0x0d;
|
||||
|
||||
Vector3 local_minimum;
|
||||
Vector3 local_maximum;
|
||||
Sphere bounding_sphere;
|
||||
|
||||
float base_xy_area;
|
||||
float base_volume;
|
||||
|
||||
uint32_t opaque_0x38;
|
||||
uint32_t opaque_0x3C;
|
||||
uint32_t opaque_0x40;
|
||||
} MSH_02_geometry_slot;
|
||||
```
|
||||
|
||||
## Key function: `ResolveMsh0x02SlotBy_LOD_and_state`
|
||||
|
||||
|
||||
The bounds functions call it as default geometry lookup:
|
||||
|
||||
```c
|
||||
MSH_02_geometry_slot *
|
||||
ResolveMsh0x02SlotBy_LOD_and_state(
|
||||
MSH_piece *this,
|
||||
EMeshPieceLodLevel lod_level,
|
||||
EMeshPieceState state
|
||||
);
|
||||
|
||||
typedef enum EMeshPieceLodLevel {
|
||||
LOD_LEVEL_MAX_0 = 0,
|
||||
LOD_LEVEL_MINUS_1 = 1,
|
||||
LOD_LEVEL_MINUS_2 = 2,
|
||||
LOD_LEVEL_MINUS_3 = 3,
|
||||
LOD_LEVEL_MINUS_4 = 4,
|
||||
} EMeshPieceLodLevel;
|
||||
|
||||
typedef enum EMeshPieceState {
|
||||
MODEL_STATE_DEFAULT = -1,
|
||||
MODEL_STATE_REGULAR = 0,
|
||||
MODEL_STATE_COLLAPSED = 1,
|
||||
_MODEL_STATE_UNKNOWN_2 = 2,
|
||||
} EMeshPieceState;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key function: `IJointMesh_of_AniMesh::ComputeJointBoundingBox`
|
||||
|
||||
Preferred name:
|
||||
|
||||
```c
|
||||
AniMesh_IJointMesh::ComputeJointBoundingBox
|
||||
```
|
||||
|
||||
Core behavior:
|
||||
|
||||
```text
|
||||
1. Read the joint/piece placement matrix in requested space.
|
||||
2. Resolve default geometry slot for the queried piece.
|
||||
3. If the piece has geometry:
|
||||
- build local box from slot local_minimum/local_maximum;
|
||||
- optionally scale by mesh_scale;
|
||||
- transform all corners by the joint matrix.
|
||||
4. If the piece has no geometry:
|
||||
- create a degenerate box at joint transform origin.
|
||||
5. If scope is single-piece, return.
|
||||
6. If queried piece is root piece 0, return cached whole-mesh bounds.
|
||||
7. Otherwise recursively include matching children, controlled by JointBoundsFilter and MSH01 flags.
|
||||
```
|
||||
|
||||
Important meaning:
|
||||
|
||||
```text
|
||||
This is the main recursive piece-tree bounds function.
|
||||
```
|
||||
|
||||
Geometry-less piece meaning:
|
||||
|
||||
```text
|
||||
No MSH 0x02 slot
|
||||
-> helper/socket joint
|
||||
-> point-sized bounds at joint transform origin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key function: `IJointMesh_of_AniMesh::ComputeJointBoundingSphere`
|
||||
|
||||
Preferred name:
|
||||
|
||||
```c
|
||||
AniMesh_IJointMesh::ComputeJointBoundingSphere
|
||||
```
|
||||
|
||||
Core behavior:
|
||||
|
||||
```text
|
||||
If scope != SINGLE_PIECE and piece != 0:
|
||||
ComputeJointBoundingBox(...)
|
||||
Convert resulting AABB to center/radius sphere.
|
||||
|
||||
If scope != SINGLE_PIECE and piece == 0:
|
||||
Use cached whole-mesh or cached filtered mesh sphere.
|
||||
|
||||
If scope == SINGLE_PIECE:
|
||||
Use MSH_02_geometry_slot::bounding_sphere.
|
||||
If no geometry slot, return zero-radius sphere at joint origin.
|
||||
```
|
||||
|
||||
Important meaning:
|
||||
|
||||
```text
|
||||
Subtree sphere is not a tight recursive sphere.
|
||||
It is an AABB-derived sphere from ComputeJointBoundingBox.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Important conceptual model
|
||||
|
||||
```text
|
||||
CAniMesh
|
||||
owns one flat pieces_vector
|
||||
|
||||
Each loaded .msh
|
||||
contributes one tagged group of pieces
|
||||
|
||||
MSH 0x01
|
||||
source node hierarchy inside one .msh
|
||||
|
||||
MSH_piece
|
||||
runtime node/piece inside CAniMesh::pieces_vector
|
||||
|
||||
parent_piece_index
|
||||
absolute runtime parent index in CAniMesh::pieces_vector
|
||||
|
||||
msh_tag
|
||||
tells which loaded .msh/submodel this runtime piece came from
|
||||
```
|
||||
|
||||
Runtime result:
|
||||
|
||||
```text
|
||||
Multiple .msh files become one combined piece tree.
|
||||
The tag preserves source submodel grouping.
|
||||
The parent indices define the actual runtime hierarchy.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example runtime structure
|
||||
|
||||
```text
|
||||
CAniMesh pieces_vector
|
||||
|
||||
piece[0] body_root tag = 0, SUBMODEL_ROOT
|
||||
├─ piece[1] left_track tag = 0
|
||||
├─ piece[2] right_track tag = 0
|
||||
└─ piece[3] turret_socket tag = 0
|
||||
└─ piece[4] turret_base tag = 1, SUBMODEL_ROOT
|
||||
└─ piece[5] turret_rotor tag = 1
|
||||
├─ piece[6] cannon_socket tag = 1
|
||||
│ └─ piece[8] cannon_body tag = 2, SUBMODEL_ROOT
|
||||
└─ piece[7] rocket_socket tag = 1
|
||||
└─ piece[9] launcher_body tag = 3, SUBMODEL_ROOT
|
||||
```
|
||||
|
||||
Key rule:
|
||||
|
||||
```text
|
||||
tag groups pieces by loaded .msh.
|
||||
parent_piece_index builds the actual hierarchy.
|
||||
```
|
||||
@@ -0,0 +1,347 @@
|
||||
# Документация формата MSH
|
||||
|
||||
Формат `.msh` используется игрой Parkan: Железная стратегия (1998) для хранения 3D-мешей.
|
||||
MSH файлы — это NRes архивы, содержащие несколько типизированных компонентов.
|
||||
|
||||
## Обзор
|
||||
|
||||
Существует **два варианта** формата MSH:
|
||||
|
||||
| Вариант | Применение | Ключевые компоненты | Хранение треугольников |
|
||||
|---------|------------|---------------------|------------------------|
|
||||
| **Модель** | Роботы, здания, объекты | 06, 0D, 07 | Индексированные треугольники |
|
||||
| **Ландшафт** | Террейн | 0B, 15 | Прямые треугольники |
|
||||
|
||||
### Автоопределение типа
|
||||
|
||||
```
|
||||
Модель: Есть компонент 06 (индексы) И 0D (батчи)
|
||||
Ландшафт: Есть компонент 0B (материалы) И НЕТ компонента 06
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сводка компонентов
|
||||
|
||||
| Тип | Название | Размер элемента | Описание |
|
||||
|:---:|----------|:---------------:|----------|
|
||||
| 01 | Node table | 38 (0x26), редко 24 | Узлы модели / тайлы; старое имя: Pieces |
|
||||
| 02 | Header + slots | 0x8C + n*68 | Общий заголовок и slot records; старое имя: Submeshes |
|
||||
| 03 | Positions | 12 (0x0C) | Позиции вершин (Vector3); старое имя: Vertices |
|
||||
| 04 | PackedNormals | 4 | `int8[4]`, normal = clamp(component / 127.0, -1..1) |
|
||||
| 05 | PackedUV0 | 4 | `int16[2]`, uv = component / 1024.0 |
|
||||
| 06 | Index buffer | 2 | Индексы вершин треугольников |
|
||||
| 07 | Tri descriptors | 16 | Описатели треугольников для коллизии/пикинга |
|
||||
| 08 | AnimKeyPool | 24 | Кейфреймы анимации меша |
|
||||
| 0A | Node strings | переменный | Строки узлов; старое имя: ExternalRefs |
|
||||
| 0B | неизвестно | 4 | неизвестно (только Ландшафт) |
|
||||
| 0D | Batch table | 20 (0x14) | Батчи рендера; FParkan Res13 decimal |
|
||||
| 0E | неизвестно | 4 | неизвестно (только Ландшафт) |
|
||||
| 12 | MicrotextureMap | 4 | неизвестно |
|
||||
| 13 | AnimMap | 2 | Карта кадров анимации, на нее указывает `AnimMapStart` из 0x01 |
|
||||
| 15 | TerrainTriangle table | 28 (0x1C) | Terrain-гипотеза |
|
||||
|
||||
---
|
||||
|
||||
## Поток данных
|
||||
|
||||
### Модель (роботы, здания)
|
||||
|
||||
```
|
||||
Компонент 01 (Pieces - части)
|
||||
│
|
||||
└─► Lod[n] ──► Компонент 02 (индекс сабмеша)
|
||||
│
|
||||
├─► TriStart:TriCount ──► Компонент 07 (данные на треугольник)
|
||||
│
|
||||
└─► BatchStart:BatchCount ──► Компонент 0D (батчи)
|
||||
│
|
||||
├─► IndexStart:IndexCount ──► Компонент 06 (индексы)
|
||||
│ │
|
||||
│ └─► Компонент 03 (вершины)
|
||||
│
|
||||
└─► BaseVertex (базовое смещение вершины)
|
||||
```
|
||||
|
||||
### Ландшафт (террейн)
|
||||
|
||||
```
|
||||
Компонент 01 (Тайлы, обычно 16×16 = 256)
|
||||
│
|
||||
└─► Lod[n] ──► Компонент 02 (индекс сабмеша)
|
||||
│
|
||||
└─► TriStart:TriCount ──► Компонент 15 (треугольники)
|
||||
│
|
||||
└─► Vertex1/2/3Index ──► Компонент 03 (вершины)
|
||||
|
||||
└─► TriStart:TriCount ──► Компонент 0B (материалы, параллельно 15)
|
||||
```
|
||||
|
||||
**Важно:** В ландшафтных мешах поля `TriStart` и `TriCount` в Компоненте 02
|
||||
используются для индексации в Компонент 15 (треугольники), а не в Компонент 07.
|
||||
|
||||
---
|
||||
|
||||
## Структуры компонентов
|
||||
|
||||
### Компонент 0x01 - Node table (0x26 = 38 байт)
|
||||
|
||||
Определяет узлы модели или тайлы terrain. Старое локальное имя: Pieces / SubMesh.
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 2 | uint16 | Header0 | Заголовочное слово узла; старые имена: Type1 + Type2 |
|
||||
| 0x02 | 2 | uint16 | ParentOrLink | Индекс родителя/ссылка; старый локальный тип int16 показывал 0xFFFF как -1 |
|
||||
| 0x04 | 2 | uint16 | AnimMapStart | Начало блока в 0x13 или 0xFFFF; старое имя: OffsetIntoFile13 |
|
||||
| 0x06 | 2 | uint16 | FallbackKey | Индекс fallback-ключа в 0x08; старое имя: IndexInFile08 |
|
||||
| 0x08 | 30 | ushort[15] | SlotIndex | Индексы slot в 0x02 по формуле `lod * 5 + group`; старое имя: Lod |
|
||||
|
||||
**Ландшафт:** 256 тайлов в сетке 16×16. Каждый тайл имеет 2 LOD (индексы 0-255 и 256-511).
|
||||
|
||||
---
|
||||
|
||||
### Компонент 0x02 - Header + slots (Заголовок: 0x8C = 140 байт, slot: 0x44 = 68 байт)
|
||||
|
||||
#### Заголовок (140 байт)
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 96 | Vector3[8] | BoundingBox | 8-точечный баундинг-бокс |
|
||||
| 0x60 | 12 | Vector3 | Center | Центральная точка |
|
||||
| 0x6C | 4 | float | CenterW | W-компонента |
|
||||
| 0x70 | 12 | Vector3 | Bottom | Нижняя точка |
|
||||
| 0x7C | 12 | Vector3 | Top | Верхняя точка |
|
||||
| 0x88 | 4 | float | XYRadius | Радиус в плоскости XY |
|
||||
|
||||
#### Элемент (68 байт)
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 2 | ushort | TriStart | Начальный индекс в Компоненте 07; в landscape-tooling может указывать в 15 |
|
||||
| 0x02 | 2 | ushort | TriCount | Количество записей в Компоненте 07; в landscape-tooling может быть count для 15 |
|
||||
| 0x04 | 2 | ushort | BatchStart | Начальное смещение в Компоненте 0D (только Модель) |
|
||||
| 0x06 | 2 | ushort | BatchCount | Количество батчей в Компоненте 0D (только Модель) |
|
||||
| 0x08 | 12 | Vector3 | LocalMinimum | Минимум локального баундинг-бокса |
|
||||
| 0x14 | 12 | Vector3 | LocalMaximum | Максимум локального баундинг-бокса |
|
||||
| 0x20 | 12 | Vector3 | Center | Центр сабмеша |
|
||||
| 0x2C | 4 | float | SphereRadius | Радиус bounding sphere; старый `Vector4` был overlay-гипотезой |
|
||||
| 0x30 | 20 | uint32[5] | Opaque | Непонятый tail, сохранять 1:1; старый `Vector5` был overlay-гипотезой |
|
||||
|
||||
---
|
||||
|
||||
### Компонент 03 - Vertices (0x0C = 12 байт)
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 4 | float | X | Координата X |
|
||||
| 0x04 | 4 | float | Y | Координата Y |
|
||||
| 0x08 | 4 | float | Z | Координата Z |
|
||||
|
||||
---
|
||||
|
||||
### Компонент 06 - Indices (2 байта) - Только Модель
|
||||
|
||||
Массив `ushort` значений — индексы вершин треугольников.
|
||||
Используются группами по 3 для каждого треугольника. Ссылки через батчи Компонента 0D.
|
||||
|
||||
---
|
||||
|
||||
### Компонент 0x07 - Tri descriptors (0x10 = 16 байт)
|
||||
|
||||
Описатели треугольников для коллизии/пикинга.
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 2 | ushort | TriFlags | Флаги треугольника; старое имя: Flags |
|
||||
| 0x02 | 2 | ushort | Link0 | Связь/opaque поле 0; старое имя: Magic02 |
|
||||
| 0x04 | 2 | ushort | Link1 | Связь/opaque поле 1; старое имя: Magic04 |
|
||||
| 0x06 | 2 | ushort | Link2 | Связь/opaque поле 2; старое имя: Magic06 |
|
||||
| 0x08 | 2 | int16 | NormalX | Упакованная X-компонента нормали; старое имя: OffsetX |
|
||||
| 0x0A | 2 | int16 | NormalY | Упакованная Y-компонента нормали; старое имя: OffsetY |
|
||||
| 0x0C | 2 | int16 | NormalZ | Упакованная Z-компонента нормали; старое имя: OffsetZ |
|
||||
| 0x0E | 2 | ushort | SelectorPacked | Три 2-битных селектора; `3` трактуется как `0xFFFF`; старое имя: Magic14 |
|
||||
|
||||
---
|
||||
|
||||
### Компонент 0B - Material Data (4 байта) - Только Ландшафт
|
||||
|
||||
Информация о материале/текстуре на каждый треугольник. Параллельный массив к Компоненту 15.
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 2 | ushort | HighWord | Индекс материала/текстуры |
|
||||
| 0x02 | 2 | ushort | LowWord | Индекс треугольника (последовательный) |
|
||||
|
||||
---
|
||||
|
||||
### Компонент 0x0D - Batch table (0x14 = 20 байт)
|
||||
|
||||
Определяет батчи вызовов отрисовки. В терминах FParkan это Res13 decimal.
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 2 | ushort | BatchFlags / Flags.low | Флаги батча |
|
||||
| 0x02 | 2 | ushort | MaterialIndex / Flags.high | Индекс material slot |
|
||||
| 0x04 | 2 | ushort | Opaque4 | Opaque, старое имя `TriangleCount` не подтверждено |
|
||||
| 0x06 | 2 | ushort | Opaque6 | Opaque |
|
||||
| 0x08 | 2 | ushort | IndexCount | Количество индексов для отрисовки в 0x06 |
|
||||
| 0x0A | 4 | uint32 | IndexStart | Начальный индекс в Компоненте 06 |
|
||||
| 0x0E | 2 | ushort | Opaque14 | Opaque, старое имя `CountOf03` не подтверждено |
|
||||
| 0x10 | 4 | uint32 | BaseVertex | Базовое смещение вершины в Компоненте 03 |
|
||||
|
||||
---
|
||||
|
||||
### Компонент 0x15 - TerrainTriangle table (0x1C = 28 байт)
|
||||
|
||||
Прямые определения terrain-треугольников. Это hex-компонент 0x15 проекта, не FParkan Res15 decimal.
|
||||
|
||||
| Смещение | Размер | Тип | Поле | Описание |
|
||||
|:--------:|:------:|:---:|------|----------|
|
||||
| 0x00 | 4 | uint32 | Flags | Флаги треугольника (0x20000 = коллизия) |
|
||||
| 0x04 | 4 | uint32 | MaterialData | Данные материала; старое имя: Magic04 |
|
||||
| 0x08 | 2 | ushort | Vertex1Index | Индекс первой вершины |
|
||||
| 0x0A | 2 | ushort | Vertex2Index | Индекс второй вершины |
|
||||
| 0x0C | 2 | ushort | Vertex3Index | Индекс третьей вершины |
|
||||
| 0x0E | 4 | uint32 | Opaque0E | Opaque; старое имя: Magic0E |
|
||||
| 0x12 | 4 | uint32 | Opaque12 | Opaque; старое имя: Magic12 |
|
||||
| 0x16 | 4 | uint32 | Opaque16 | Opaque; старое имя: Magic16 |
|
||||
| 0x1A | 2 | ushort | Opaque1A | Opaque; старое имя: Magic1A |
|
||||
|
||||
#### MaterialData (0x04) - Структура материала
|
||||
|
||||
```
|
||||
MaterialData = 0xFFFF_SSPP
|
||||
│ │└─ PP: Основной материал (byte 0)
|
||||
│ └─── SS: Вторичный материал для блендинга (byte 1)
|
||||
└────── Всегда 0xFFFF (байты 2-3)
|
||||
```
|
||||
|
||||
| Значение SS | Описание |
|
||||
|:-----------:|----------|
|
||||
| 0xFF | Сплошной материал (без блендинга) |
|
||||
| 0x01-0xFE | Индекс вторичного материала для блендинга |
|
||||
|
||||
Примеры:
|
||||
- `0xFFFFFF01` = Сплошной материал 1
|
||||
- `0xFFFF0203` = Материал 3 с блендингом в материал 2
|
||||
|
||||
---
|
||||
|
||||
### Компонент 0A - External References (переменный размер)
|
||||
|
||||
Таблица строк для внешних ссылок на части меша. Формат:
|
||||
|
||||
```
|
||||
[4 байта: длина] [байты строки] [null-терминатор]
|
||||
...повтор...
|
||||
```
|
||||
|
||||
Длина 0 означает пустую запись. Строки типа `"central"` имеют особое значение (flag |= 1).
|
||||
|
||||
---
|
||||
|
||||
## Пример: Ландшафт SC_1
|
||||
|
||||
```
|
||||
Land.msh (SC_1):
|
||||
├── 01: 256 тайлов (сетка 16×16)
|
||||
├── 02: 512 сабмешей (256 LOD0 + 256 LOD1)
|
||||
├── 03: 10 530 вершин
|
||||
├── 04: 10 530 данных на вершину
|
||||
├── 05: 10 530 данных на вершину
|
||||
├── 0B: 7 882 записи материалов
|
||||
├── 0E: 10 530 данных на вершину
|
||||
├── 12: 10 530 микротекстурный маппинг
|
||||
└── 15: 7 882 треугольника
|
||||
├── LOD 0: 4 993 треугольника (тайлы 0-255 → сабмеши 0-255)
|
||||
└── LOD 1: 2 889 треугольников (тайлы 0-255 → сабмеши 256-511)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Использование
|
||||
|
||||
```csharp
|
||||
var converter = new MshConverter();
|
||||
|
||||
// Автоопределение типа и конвертация в OBJ
|
||||
converter.Convert("Land.msh", "terrain.obj", lodLevel: 0);
|
||||
converter.Convert("robot.msh", "robot.obj", lodLevel: 0);
|
||||
|
||||
// Ручное определение типа
|
||||
var archive = NResParser.ReadFile("mesh.msh").Archive;
|
||||
var type = MshConverter.DetectMeshType(archive);
|
||||
// Возвращает: MshType.Model или MshType.Landscape
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Формат WEA - Файлы материалов ландшафта
|
||||
|
||||
Файлы `.wea` — текстовые файлы, определяющие таблицу материалов для ландшафта.
|
||||
|
||||
### Формат
|
||||
|
||||
```
|
||||
{count}
|
||||
{index} {material_name}
|
||||
{index} {material_name}
|
||||
...
|
||||
```
|
||||
|
||||
### Связь с Land.msh
|
||||
|
||||
Каждая карта имеет два файла материалов:
|
||||
|
||||
| Файл | Используется для | Треугольники в Comp15 |
|
||||
|------|------------------|----------------------|
|
||||
| `Land1.wea` | LOD0 (высокая детализация) | Первые N (сумма TriCount для LOD0) |
|
||||
| `Land2.wea` | LOD1 (низкая детализация) | Остальные |
|
||||
|
||||
### Пример (SC_1)
|
||||
|
||||
**Land1.wea:**
|
||||
```
|
||||
4
|
||||
0 B_S0
|
||||
1 L04
|
||||
2 L02
|
||||
3 L00
|
||||
```
|
||||
|
||||
**Land2.wea:**
|
||||
```
|
||||
4
|
||||
0 DEFAULT
|
||||
1 L05
|
||||
2 L03
|
||||
3 L01
|
||||
```
|
||||
|
||||
### Маппинг материалов
|
||||
|
||||
Индекс материала в `Comp15.MaterialData & 0xFF` → строка в `.wea` файле.
|
||||
|
||||
```
|
||||
Треугольник с MaterialData = 0xFFFF0102
|
||||
└─ Основной материал = 02 → Land1.wea[2] = "L02"
|
||||
└─ Блендинг с материалом = 01 → Land1.wea[1] = "L04"
|
||||
```
|
||||
|
||||
### Типичные имена материалов
|
||||
|
||||
| Префикс | Назначение |
|
||||
|---------|------------|
|
||||
| L00-L05 | Текстуры ландшафта (grass, dirt, etc.) |
|
||||
| B_S0 | Базовая текстура |
|
||||
| DEFAULT | Фолбэк для LOD1 |
|
||||
| WATER | Вода (поверхность) |
|
||||
| WATER_BOT | Вода (дно) |
|
||||
| WATER_M | Вода LOD1 |
|
||||
|
||||
---
|
||||
|
||||
## Источники
|
||||
|
||||
- Реверс-инжиниринг `Terrain.dll` (класс CLandscape)
|
||||
- Декомпиляция Ghidra: `CLandscape::ctor` и `IMesh2_of_CLandscape::Render`
|
||||
Reference in New Issue
Block a user