refactor into projects

This commit is contained in:
bird_egop
2026-05-17 15:40:16 +03:00
parent a5a14ec4ed
commit 28d10f3ffa
25 changed files with 77 additions and 27 deletions
-86
View File
@@ -1,86 +0,0 @@
using Common;
namespace ParkanPlayground;
public record BasContours(InnerContour[] Inner, OuterContour[] Outer);
public record InnerContour(Vector3[] Points, uint[] Nums1, uint[] Nums2);
public record OuterContour(Vector3[] Points);
public static class BasParser
{
public static BasContours ReadFile(string path)
{
using FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return ReadFile(fs);
}
public static BasContours ReadFile(Stream fs)
{
var innerContourCount = fs.ReadUInt32LittleEndian();
InnerContour[] innerContours = new InnerContour[innerContourCount];
for (var i = 0; i < innerContourCount; i++)
{
var pointCount = fs.ReadUInt32LittleEndian();
Vector3[] points = new Vector3[pointCount + 1];
for (var j = 0; j < pointCount + 1; j++)
{
var x = fs.ReadFloatLittleEndian();
var y = fs.ReadFloatLittleEndian();
var z = fs.ReadFloatLittleEndian();
points[j] = new Vector3(x, y, z);
}
uint[] nums1 = new uint[pointCount];
// batch 1
for (var j = 0; j < pointCount; j++)
{
var num = fs.ReadUInt32LittleEndian();
nums1[j] = num;
}
uint[] nums2 = new uint[pointCount];
// batch 2
for (var j = 0; j < pointCount; j++)
{
var num = fs.ReadUInt32LittleEndian();
nums2[j] = num;
}
innerContours[i] = new InnerContour(points, nums1, nums2);
}
var outerContourCount = fs.ReadUInt32LittleEndian();
OuterContour[] outerContours = new OuterContour[outerContourCount];
for (var i = 0; i < outerContourCount; i++)
{
var pointCount = fs.ReadUInt32LittleEndian();
Vector3[] points = new Vector3[pointCount + 1];
for (var j = 0; j < pointCount + 1; j++)
{
var x = fs.ReadFloatLittleEndian();
var y = fs.ReadFloatLittleEndian();
var z = fs.ReadFloatLittleEndian();
points[j] = new Vector3(x, y, z);
}
outerContours[i] = new OuterContour(points);
}
BasContours contours = new BasContours(innerContours, outerContours);
return contours;
}
}
-337
View File
@@ -1,337 +0,0 @@
# 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.
```
-260
View File
@@ -1,260 +0,0 @@
using Common;
namespace ParkanPlayground.Effects;
/// <summary>
/// Static reader methods for parsing FXID effect definition structures from binary streams.
/// </summary>
public static class FxidReader
{
/// <summary>
/// Reads a Vector3 (3 floats: X, Y, Z) from the binary stream.
/// </summary>
public static Vector3 ReadVector3(BinaryReader br)
{
float x = br.ReadSingle();
float y = br.ReadSingle();
float z = br.ReadSingle();
return new Vector3(x, y, z);
}
/// <summary>
/// Reads the 60-byte effect header from the binary stream.
/// </summary>
public static EffectHeader ReadEffectHeader(BinaryReader br)
{
EffectHeader h;
h.ComponentCount = br.ReadUInt32();
h.TimeMode = br.ReadUInt32();
h.Duration = br.ReadSingle();
h.PhaseJitter = br.ReadSingle();
h.Flags = br.ReadUInt32();
h.SettingsId = br.ReadUInt32();
h.RandShiftX = br.ReadSingle();
h.RandShiftY = br.ReadSingle();
h.RandShiftZ = br.ReadSingle();
h.PivotX = br.ReadSingle();
h.PivotY = br.ReadSingle();
h.PivotZ = br.ReadSingle();
h.ScaleX = br.ReadSingle();
h.ScaleY = br.ReadSingle();
h.ScaleZ = br.ReadSingle();
return h;
}
/// <summary>
/// Reads a BillboardComponentData (type 1) from the binary stream.
/// </summary>
public static BillboardComponentData ReadBillboardComponent(BinaryReader br, uint typeAndFlags)
{
BillboardComponentData d;
d.TypeAndFlags = typeAndFlags;
d.Unknown04 = br.ReadSingle();
d.ScalarAMin = br.ReadSingle();
d.ScalarAMax = br.ReadSingle();
d.ScalarAExp = br.ReadSingle();
d.ActiveTimeStart = br.ReadSingle();
d.ActiveTimeEnd = br.ReadSingle();
d.SampleSpreadParam = br.ReadSingle();
d.Unknown20 = br.ReadUInt32();
d.PrimarySampleCount = br.ReadUInt32();
d.SecondarySampleCount = br.ReadUInt32();
d.ExtentVec0 = ReadVector3(br);
d.ExtentVec1 = ReadVector3(br);
d.ExtentVec2 = ReadVector3(br);
d.ExponentTriplet0 = ReadVector3(br);
d.RadiusTriplet0 = ReadVector3(br);
d.RadiusTriplet1 = ReadVector3(br);
d.RadiusTriplet2 = ReadVector3(br);
d.ExponentTriplet1 = ReadVector3(br);
d.NoiseAmplitude = br.ReadSingle();
d.Reserved = br.ReadBytes(0x50);
return d;
}
/// <summary>
/// Reads a SoundComponentData (type 2) from the binary stream.
/// </summary>
public static SoundComponentData ReadSoundComponent(BinaryReader br, uint typeAndFlags)
{
SoundComponentData d;
d.TypeAndFlags = typeAndFlags;
d.PlayMode = br.ReadUInt32();
d.StartTime = br.ReadSingle();
d.EndTime = br.ReadSingle();
d.Pos0 = ReadVector3(br);
d.Pos1 = ReadVector3(br);
d.Offset0 = ReadVector3(br);
d.Offset1 = ReadVector3(br);
d.Scalar0Min = br.ReadSingle();
d.Scalar0Max = br.ReadSingle();
d.Scalar1Min = br.ReadSingle();
d.Scalar1Max = br.ReadSingle();
d.SoundFlags = br.ReadUInt32();
d.SoundNameAndReserved = br.ReadBytes(0x40);
return d;
}
/// <summary>
/// Reads an AnimParticleComponentData (type 3) from the binary stream.
/// </summary>
public static AnimParticleComponentData ReadAnimParticleComponent(BinaryReader br, uint typeAndFlags)
{
AnimParticleComponentData d;
d.TypeAndFlags = typeAndFlags;
d.Unknown04 = br.ReadSingle();
d.ScalarAMin = br.ReadSingle();
d.ScalarAMax = br.ReadSingle();
d.ScalarAExp = br.ReadSingle();
d.ActiveTimeStart = br.ReadSingle();
d.ActiveTimeEnd = br.ReadSingle();
d.SampleSpreadParam = br.ReadSingle();
d.Unknown20 = br.ReadUInt32();
d.PrimarySampleCount = br.ReadUInt32();
d.SecondarySampleCount = br.ReadUInt32();
d.ExtentVec0 = ReadVector3(br);
d.ExtentVec1 = ReadVector3(br);
d.ExtentVec2 = ReadVector3(br);
d.ExponentTriplet0 = ReadVector3(br);
d.RadiusTriplet0 = ReadVector3(br);
d.RadiusTriplet1 = ReadVector3(br);
d.RadiusTriplet2 = ReadVector3(br);
d.ExponentTriplet1 = ReadVector3(br);
d.NoiseAmplitude = br.ReadSingle();
d.Reserved = br.ReadBytes(0x38);
return d;
}
/// <summary>
/// Reads an AnimBillboardComponentData (type 4) from the binary stream.
/// </summary>
public static AnimBillboardComponentData ReadAnimBillboardComponent(BinaryReader br, uint typeAndFlags)
{
AnimBillboardComponentData d;
d.TypeAndFlags = typeAndFlags;
d.Unknown04 = br.ReadSingle();
d.ScalarAMin = br.ReadSingle();
d.ScalarAMax = br.ReadSingle();
d.ScalarAExp = br.ReadSingle();
d.ActiveTimeStart = br.ReadSingle();
d.ActiveTimeEnd = br.ReadSingle();
d.SampleSpreadParam = br.ReadSingle();
d.Unknown20 = br.ReadUInt32();
d.PrimarySampleCount = br.ReadUInt32();
d.SecondarySampleCount = br.ReadUInt32();
d.ExtentVec0 = ReadVector3(br);
d.ExtentVec1 = ReadVector3(br);
d.ExtentVec2 = ReadVector3(br);
d.ExponentTriplet0 = ReadVector3(br);
d.RadiusTriplet0 = ReadVector3(br);
d.RadiusTriplet1 = ReadVector3(br);
d.RadiusTriplet2 = ReadVector3(br);
d.ExponentTriplet1 = ReadVector3(br);
d.NoiseAmplitude = br.ReadSingle();
d.Reserved = br.ReadBytes(0x3C);
return d;
}
/// <summary>
/// Reads a TrailComponentData (type 5) from the binary stream.
/// </summary>
public static TrailComponentData ReadTrailComponent(BinaryReader br, uint typeAndFlags)
{
TrailComponentData d;
d.TypeAndFlags = typeAndFlags;
d.Unknown04To10 = br.ReadBytes(0x10);
d.SegmentCount = br.ReadUInt32();
d.Param0 = br.ReadSingle();
d.Param1 = br.ReadSingle();
d.Unknown20 = br.ReadUInt32();
d.Unknown24 = br.ReadUInt32();
d.ActiveTimeStart = br.ReadSingle();
d.ActiveTimeEnd = br.ReadSingle();
d.TextureNameAndReserved = br.ReadBytes(0x40);
return d;
}
/// <summary>
/// Reads a PointComponentData (type 6) from the binary stream.
/// Note: Point components have no payload beyond the typeAndFlags header.
/// </summary>
public static PointComponentData ReadPointComponent(uint typeAndFlags)
{
PointComponentData d;
d.TypeAndFlags = typeAndFlags;
return d;
}
/// <summary>
/// Reads a PlaneComponentData (type 7) from the binary stream.
/// </summary>
public static PlaneComponentData ReadPlaneComponent(BinaryReader br, uint typeAndFlags)
{
PlaneComponentData d;
d.Base = ReadAnimParticleComponent(br, typeAndFlags);
d.ExtraPlaneParam0 = br.ReadUInt32();
d.ExtraPlaneParam1 = br.ReadUInt32();
return d;
}
/// <summary>
/// Reads a ModelComponentData (type 8) from the binary stream.
/// </summary>
public static ModelComponentData ReadModelComponent(BinaryReader br, uint typeAndFlags)
{
ModelComponentData d;
d.TypeAndFlags = typeAndFlags;
d.Unk04 = br.ReadBytes(0x14);
d.ActiveTimeStart = br.ReadSingle();
d.ActiveTimeEnd = br.ReadSingle();
d.Unknown20 = br.ReadUInt32();
d.InstanceCount = br.ReadUInt32();
d.BasePos = ReadVector3(br);
d.OffsetPos = ReadVector3(br);
d.ScatterExtent = ReadVector3(br);
d.Axis0 = ReadVector3(br);
d.Axis1 = ReadVector3(br);
d.Axis2 = ReadVector3(br);
d.Reserved70 = br.ReadBytes(0x18);
d.RadiusTriplet0 = ReadVector3(br);
d.RadiusTriplet1 = ReadVector3(br);
d.ReservedA0 = br.ReadBytes(0x18);
d.TextureNameAndFlags = br.ReadBytes(0x40);
return d;
}
/// <summary>
/// Reads an AnimModelComponentData (type 9) from the binary stream.
/// </summary>
public static AnimModelComponentData ReadAnimModelComponent(BinaryReader br, uint typeAndFlags)
{
AnimModelComponentData d;
d.TypeAndFlags = typeAndFlags;
d.AnimSpeed = br.ReadSingle();
d.MinTime = br.ReadSingle();
d.MaxTime = br.ReadSingle();
d.Exponent = br.ReadSingle();
d.Reserved14 = br.ReadBytes(0x14);
d.DirVec0 = ReadVector3(br);
d.Reserved34 = br.ReadBytes(0x0C);
d.RadiusTriplet0 = ReadVector3(br);
d.DirVec1 = ReadVector3(br);
d.RadiusTriplet1 = ReadVector3(br);
d.ExtentVec0 = ReadVector3(br);
d.ExtentVec1 = ReadVector3(br);
d.Reserved7C = br.ReadBytes(0x0C);
d.TextureNameAndFlags = br.ReadBytes(0x48);
return d;
}
/// <summary>
/// Reads a CubeComponentData (type 10) from the binary stream.
/// </summary>
public static CubeComponentData ReadCubeComponent(BinaryReader br, uint typeAndFlags)
{
CubeComponentData d;
d.Base = ReadAnimBillboardComponent(br, typeAndFlags);
d.ExtraCubeParam0 = br.ReadUInt32();
return d;
}
}
-258
View File
@@ -1,258 +0,0 @@
using Common;
namespace ParkanPlayground.Effects;
/// <summary>
/// Effect-level header at the start of each FXID file (60 bytes total).
/// Parsed from CEffect_InitFromDef: defines component count, global duration/flags,
/// some unknown control fields, and the uniform scale vector applied to the effect.
/// </summary>
public record struct EffectHeader
{
/// <summary>FXID payload offset 0x00: command count.</summary>
public uint ComponentCount;
/// <summary>FXID payload offset 0x04: time mode used to compute effect alpha.</summary>
public uint TimeMode;
/// <summary>FXID payload offset 0x08: effect duration in seconds.</summary>
public float Duration;
/// <summary>FXID payload offset 0x0C: random phase shift amplitude.</summary>
public float PhaseJitter;
/// <summary>FXID payload offset 0x10: effect behavior flags.</summary>
public uint Flags;
/// <summary>FXID payload offset 0x14: settings/profile id.</summary>
public uint SettingsId;
/// <summary>FXID payload offset 0x18: random spatial shift X.</summary>
public float RandShiftX;
/// <summary>FXID payload offset 0x1C: random spatial shift Y.</summary>
public float RandShiftY;
/// <summary>FXID payload offset 0x20: random spatial shift Z.</summary>
public float RandShiftZ;
/// <summary>FXID payload offset 0x24: local pivot X.</summary>
public float PivotX;
/// <summary>FXID payload offset 0x28: local pivot Y.</summary>
public float PivotY;
/// <summary>FXID payload offset 0x2C: local pivot Z.</summary>
public float PivotZ;
/// <summary>FXID payload offset 0x30: base scale X.</summary>
public float ScaleX;
/// <summary>FXID payload offset 0x34: base scale Y.</summary>
public float ScaleY;
/// <summary>FXID payload offset 0x38: base scale Z.</summary>
public float ScaleZ;
}
/// <summary>
/// Shared on-disk definition layout for billboard-style components (type 1).
/// Used by CBillboardComponent_Initialize/Update/Render to drive size/color/alpha
/// curves and sample scattering within a 3D extent volume.
/// </summary>
public record struct BillboardComponentData
{
public uint TypeAndFlags; // type (low byte) and flags as seen in CEffect_InitFromDef
public float Unknown04; // mode / flag-like float, semantics not fully clear
public float ScalarAMin; // base scalar A (e.g. base radius)
public float ScalarAMax; // max scalar A
public float ScalarAExp; // exponent applied to scalar A curve
public float ActiveTimeStart; // activation window start (seconds)
public float ActiveTimeEnd; // activation window end (seconds)
public float SampleSpreadParam; // extra param used when scattering samples
public uint Unknown20; // used as integer param in billboard code, exact meaning unknown
public uint PrimarySampleCount; // number of samples along primary axis
public uint SecondarySampleCount; // number of samples along secondary axis
public Vector3 ExtentVec0; // base extent/origin vector
public Vector3 ExtentVec1; // extent center / offset
public Vector3 ExtentVec2; // extent size used for random boxing
public Vector3 ExponentTriplet0;// exponent triplet for size/color curve
public Vector3 RadiusTriplet0; // radius curve key 0
public Vector3 RadiusTriplet1; // radius curve key 1
public Vector3 RadiusTriplet2; // radius curve key 2
public Vector3 ExponentTriplet1;// second exponent triplet (e.g. alpha curve)
public float NoiseAmplitude; // per-sample noise amplitude
public byte[] Reserved; // 0x50-byte tail, currently not touched by billboard code
}
/// <summary>
/// 3D sound component definition (type 2).
/// Used by CSoundComponent_Initialize/Update to drive positional audio, playback
/// window, and scalar ranges (e.g. volume / pitch), plus a 0x40-byte sound name tail.
/// </summary>
public record struct SoundComponentData
{
public uint TypeAndFlags; // component type and flags
public uint PlayMode; // playback mode (looping, one-shot, etc.)
public float StartTime; // playback window start (seconds)
public float EndTime; // playback window end (seconds)
public Vector3 Pos0; // base 3D position or path start
public Vector3 Pos1; // secondary position / path end
public Vector3 Offset0; // random offset range 0
public Vector3 Offset1; // random offset range 1
public float Scalar0Min; // scalar range 0 min (e.g. volume)
public float Scalar0Max; // scalar range 0 max
public float Scalar1Min; // scalar range 1 min (e.g. pitch)
public float Scalar1Max; // scalar range 1 max
public uint SoundFlags; // misc sound control flags
public byte[] SoundNameAndReserved; // 0x40-byte tail; sound name plus padding/unused
}
/// <summary>
/// Animated particle component definition (type 3).
/// Prefix layout matches BillboardComponentData and is used to allocate a grid of
/// particle objects; the 0x38-byte tail is passed into CFxManager_LoadTexture.
/// </summary>
public record struct AnimParticleComponentData
{
public uint TypeAndFlags; // type (low byte) and flags as seen in CEffect_InitFromDef
public float Unknown04; // mode / flag-like float, semantics not fully clear
public float ScalarAMin; // base scalar A (e.g. base radius)
public float ScalarAMax; // max scalar A
public float ScalarAExp; // exponent applied to scalar A curve
public float ActiveTimeStart; // activation window start (seconds)
public float ActiveTimeEnd; // activation window end (seconds)
public float SampleSpreadParam; // extra param used when scattering particles
public uint Unknown20; // used as integer param in anim particle code, exact meaning unknown
public uint PrimarySampleCount; // number of particles along primary axis
public uint SecondarySampleCount; // number of particles along secondary axis
public Vector3 ExtentVec0; // base extent/origin vector
public Vector3 ExtentVec1; // extent center / offset
public Vector3 ExtentVec2; // extent size used for random boxing
public Vector3 ExponentTriplet0;// exponent triplet for size/color curve
public Vector3 RadiusTriplet0; // radius curve key 0
public Vector3 RadiusTriplet1; // radius curve key 1
public Vector3 RadiusTriplet2; // radius curve key 2
public Vector3 ExponentTriplet1;// second exponent triplet (e.g. alpha curve)
public float NoiseAmplitude; // per-particle noise amplitude
public byte[] Reserved; // 0x38-byte tail; forwarded to CFxManager_LoadTexture unchanged
}
/// <summary>
/// Animated billboard component definition (type 4).
/// Shares the same prefix layout as BillboardComponentData, including extents and
/// radius/exponent triplets, but uses a 0x3C-byte tail passed to CFxManager_LoadTexture.
/// </summary>
public record struct AnimBillboardComponentData
{
public uint TypeAndFlags; // type (low byte) and flags as seen in CEffect_InitFromDef
public float Unknown04; // mode / flag-like float, semantics not fully clear
public float ScalarAMin; // base scalar A (e.g. base radius)
public float ScalarAMax; // max scalar A
public float ScalarAExp; // exponent applied to scalar A curve
public float ActiveTimeStart; // activation window start (seconds)
public float ActiveTimeEnd; // activation window end (seconds)
public float SampleSpreadParam; // extra param used when scattering animated billboards
public uint Unknown20; // used as integer param in anim billboard code, exact meaning unknown
public uint PrimarySampleCount; // number of samples along primary axis
public uint SecondarySampleCount; // number of samples along secondary axis
public Vector3 ExtentVec0; // base extent/origin vector
public Vector3 ExtentVec1; // extent center / offset
public Vector3 ExtentVec2; // extent size used for random boxing
public Vector3 ExponentTriplet0;// exponent triplet for size/color curve
public Vector3 RadiusTriplet0; // radius curve key 0
public Vector3 RadiusTriplet1; // radius curve key 1
public Vector3 RadiusTriplet2; // radius curve key 2
public Vector3 ExponentTriplet1;// second exponent triplet (e.g. alpha curve)
public float NoiseAmplitude; // per-sample noise amplitude
public byte[] Reserved; // 0x3C-byte tail; forwarded to CFxManager_LoadTexture unchanged
}
/// <summary>
/// Compact definition for trail / ribbon components (type 5).
/// CTrailComponent_Initialize interprets this as segment count, width/alpha/UV
/// ranges, timing, and a shared texture name at +0x30.
/// </summary>
public record struct TrailComponentData
{
public uint TypeAndFlags; // component type and flags
public byte[] Unknown04To10; // 0x10 bytes at +4..+0x13, used only indirectly; types unknown
public uint SegmentCount; // number of trail segments (particles)
public float Param0; // first width/alpha/UV control value (start)
public float Param1; // second width/alpha/UV control value (end)
public uint Unknown20; // extra integer parameter, purpose unknown
public uint Unknown24; // extra integer parameter, purpose unknown
public float ActiveTimeStart; // trail activation start time (>= 0)
public float ActiveTimeEnd; // trail activation end time
public byte[] TextureNameAndReserved; // 0x40-byte tail containing texture name and padding/flags
}
/// <summary>
/// Simple point component definition (type 6).
/// Definition block is just the 4-byte typeAndFlags header; no extra data on disk.
/// </summary>
public record struct PointComponentData
{
public uint TypeAndFlags; // component type and flags; definition block has no payload
}
/// <summary>
/// Plane component definition (type 7).
/// Shares the same 0xC8-byte prefix layout as AnimParticleComponentData (type 3),
/// followed by two dwords of plane-specific data.
/// </summary>
public record struct PlaneComponentData
{
public AnimParticleComponentData Base; // shared 0xC8-byte prefix: time window, sample counts, extents, curves
public uint ExtraPlaneParam0; // plane-specific parameter, semantics not yet reversed
public uint ExtraPlaneParam1; // plane-specific parameter, semantics not yet reversed
}
/// <summary>
/// Static model component definition (type 8).
/// Layout fully matches the IDA typedef used by CModelComponent_Initialize:
/// time window, instance count, spatial extents/axes, radius triplets, and a
/// 0x40-byte texture name tail.
/// </summary>
public record struct ModelComponentData
{
public uint TypeAndFlags; // component type and flags
public byte[] Unk04; // 0x14-byte blob at +0x04..+0x17, purpose unclear
public float ActiveTimeStart; // activation window start (seconds), +0x18
public float ActiveTimeEnd; // activation window end (seconds), +0x1C
public uint Unknown20; // extra flags/int parameter at +0x20
public uint InstanceCount; // number of model instances to spawn at +0x24
public Vector3 BasePos; // base position of the emitter / origin, +0x28
public Vector3 OffsetPos; // positional offset applied per-instance, +0x34
public Vector3 ScatterExtent; // extent volume used for random scattering, +0x40
public Vector3 Axis0; // local axis 0 (orientation / shape), +0x4C
public Vector3 Axis1; // local axis 1 (orientation / shape), +0x58
public Vector3 Axis2; // local axis 2 (orientation / shape), +0x64
public byte[] Reserved70; // 0x18 bytes at +0x70..+0x87, not directly used
public Vector3 RadiusTriplet0; // radius / extent triplet 0 at +0x88
public Vector3 RadiusTriplet1; // radius / extent triplet 1 at +0x94
public byte[] ReservedA0; // 0x18 bytes at +0xA0..+0xB7, not directly used
public byte[] TextureNameAndFlags; // 0x40-byte tail at +0xB8: texture name + padding/flags
}
/// <summary>
/// Animated model component definition (type 9).
/// Layout derived from CAnimModelComponent_Initialize: time params, direction vectors,
/// radius triplets, extent vectors, and a 0x48-byte texture name tail.
/// </summary>
public record struct AnimModelComponentData
{
public uint TypeAndFlags; // component type and flags
public float AnimSpeed; // animation speed multiplier at +0x04
public float MinTime; // activation window start (clamped >= 0) at +0x08
public float MaxTime; // activation window end at +0x0C
public float Exponent; // exponent for time interpolation at +0x10
public byte[] Reserved14; // 0x14 bytes at +0x14..+0x27, padding
public Vector3 DirVec0; // normalized direction vector 0 at +0x28
public byte[] Reserved34; // 0x0C bytes at +0x34..+0x3F, padding
public Vector3 RadiusTriplet0; // radius triplet 0 at +0x40
public Vector3 DirVec1; // normalized direction vector 1 at +0x4C
public Vector3 RadiusTriplet1; // radius triplet 1 at +0x58
public Vector3 ExtentVec0; // extent vector 0 at +0x64
public Vector3 ExtentVec1; // extent vector 1 at +0x70
public byte[] Reserved7C; // 0x0C bytes at +0x7C..+0x87, padding
public byte[] TextureNameAndFlags; // 0x48-byte tail at +0x88: texture/model name + padding
}
/// <summary>
/// Cube component definition (type 10).
/// Shares the same 0xCC-byte prefix layout as AnimBillboardComponentData (type 4),
/// followed by one dword of cube-specific data.
/// </summary>
public record struct CubeComponentData
{
public AnimBillboardComponentData Base; // shared 0xCC-byte prefix: billboard-style time window, extents, curves
public uint ExtraCubeParam0; // cube-specific parameter, semantics not yet reversed
}
-347
View File
@@ -1,347 +0,0 @@
# Документация формата 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`
-191
View File
@@ -1,191 +0,0 @@
using System.Buffers.Binary;
using NResLib;
namespace ParkanPlayground;
/// <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 &amp; 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,
}
-187
View File
@@ -1,187 +0,0 @@
using System.Buffers.Binary;
using Common;
using NResLib;
namespace ParkanPlayground;
/// <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);
}
-43
View File
@@ -1,43 +0,0 @@
using System.Buffers.Binary;
using Common;
using NResLib;
namespace ParkanPlayground;
/// <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;
}
}
-54
View File
@@ -1,54 +0,0 @@
using NResLib;
namespace ParkanPlayground;
/// <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)
;
-50
View File
@@ -1,50 +0,0 @@
using System.Buffers.Binary;
using NResLib;
namespace ParkanPlayground;
/// <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);
-93
View File
@@ -1,93 +0,0 @@
using System.Buffers.Binary;
using NResLib;
namespace ParkanPlayground;
/// <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
);
}
-115
View File
@@ -1,115 +0,0 @@
using System.Buffers.Binary;
using Common;
using NResLib;
namespace ParkanPlayground;
/// <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.
}
-46
View File
@@ -1,46 +0,0 @@
using System.Buffers.Binary;
using Common;
using NResLib;
namespace ParkanPlayground;
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
);
}
-66
View File
@@ -1,66 +0,0 @@
using System.Buffers.Binary;
using System.Text;
using NResLib;
namespace ParkanPlayground;
/// <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;
}
}
-123
View File
@@ -1,123 +0,0 @@
using System.Buffers.Binary;
using NResLib;
namespace ParkanPlayground;
/// <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,
}
-73
View File
@@ -1,73 +0,0 @@
using System.Buffers.Binary;
using System.Numerics;
using NResLib;
namespace ParkanPlayground;
/// <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);
}
-90
View File
@@ -1,90 +0,0 @@
using System.Buffers.Binary;
using NResLib;
namespace ParkanPlayground;
/// <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
}
-320
View File
@@ -1,320 +0,0 @@
using System.Text;
using Common;
using NResLib;
namespace ParkanPlayground;
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}");
}
}
+5
View File
@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="..\MissionTmaLib\MissionTmaLib.csproj" />
<ProjectReference Include="..\MshLib\MshLib.csproj" />
<ProjectReference Include="..\NResLib\NResLib.csproj" />
<ProjectReference Include="..\PalLib\PalLib.csproj" />
<ProjectReference Include="..\ResTreeLib\ResTreeLib.csproj" />
@@ -19,4 +20,8 @@
<PackageReference Include="SixLabors.ImageSharp.Drawing" />
</ItemGroup>
<ItemGroup>
<Folder Include="Effects\" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -1,5 +1,5 @@
using Common;
using ParkanPlayground;
using MshLib;
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
-99
View File
@@ -1,99 +0,0 @@
Допустим, игровой объект — **танк/бот с башней, пушкой и ракетницей**.
```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 так, чтобы указывать в нужное место.