Scope: the custom payloads stored by the following scene entities inside a 3ds Max scene file: the Ryzom/NeL patch object (RPO) and Edit Patch modifier; Editable Poly object; Poly Select modifier; Edit Poly modifier; Editable Mesh object; Edit Normals modifier; shared paint-selection payloads; eight material/texture types; Biped system object; NeL scripted "extends" plugins / PACS; Edit Mesh / Physique mod-app payloads; Shape-superclass (SplineShape/Line/Rectangle); UVW Map; and the custom Map Extender modifier. The outer .max compound-file container, the scene reference graph, ParamBlock/ParamBlock2 encoding, and Mesh, PatchMesh, MNMesh, BitArray, Matrix3, Spline3D, named-set-list, and wide-string encodings are external formats referenced but not defined here.
pipeline_maxWhere this comes from. Parts A–B describe the serializers of the in-tree NeL plugin sources (nel/tools/3d/plugin_max/nel_patch_lib/rpo.cpp + nel_patch_mesh.cpp, nel_patch_edit/np_epm_file.cpp + np_edit_patch_data.cpp + np_ep_vert_mapper.cpp) and were re-verified against them line-by-line (2026-07-06); the corrections from that pass are folded in below and the affected statements are marked SOURCE-VERIFIED. Parts C–I describe the corresponding 3ds Max editable-object and material serializers of the same era; that code is not in this repository, so those parts carry the confidence of a careful source reading but no in-tree re-verification and no corpus validation yet — treat their unhedged claims as "per the original serializer source".
What this page is for. It catalogues the on-disk payloads: field by field, what the bytes are. It is the reference map for the day these chunks get typed decoders in the headless pipeline (geometry for shape export; materials for milestone §12.5 (g) of Pipeline Max Design).
Implementation is governed by Pipeline Max Design, not by this page. Reader/writer directives from the original implementations have been deliberately edited out; original-loader behavior is mentioned only where it pins down a format fact — version variants, fixed vs. free chunk order, values that are load-time-derived rather than stored, fields whose stored value the original discards. Under our rules, everything stored roundtrips byte-exactly (the T2 gate), including semantically redundant data such as interpolation caches, reserved bytes, and duplicated fields; unknown chunks ride through raw; and a typed mapping only lands once it reproduces every corpus instance byte-exactly (design doc §12.2).
Where these streams sit in the pipeline_max object model today:
| This page | .max chunk ids as seen by pipeline_max |
Current handling |
|---|---|---|
| Part A (RPO object) | 0x08FD + PatchMesh/Mesh chunk streams on the RPO scene object | typed — NELPATCH::CRklPatchObject (pipeline_max/nelpatch/) claims the known ids head-first and re-emits verbatim; rpo_data.{h,cpp} decodes the 0x08FD blob, the PatchMesh element containers (§A.5) and the Edit Patch vert mapper; Mesh cache ids ride raw (zero orphans over the 1201-file zone corpus) |
| Part B (Edit Patch modifier) | modifier + per-node local data | modifier object untyped; the per-node local data (0x2500 slots → 0x2512 → 0x1000) is decoded and evaluated by pipeline_max_export_zone (final patch 0x1140, RPatchMesh 0x4001, vert mapper 0x1130 — §B.2/B.3), incl. the NeL Patch Painter variant (same shape, no mapper) |
| Part C (Editable Poly stream) | 0x4039, 0x403A, {0x3003+0x3004}×n, 0x3002, 0x4038 | exactly the known-unknown sequence EPOLY::CEditablePoly::parse drains and re-emits verbatim (epoly/editable_poly.cpp) |
Part C <PolyObject base data> (MNMesh) |
PolyObject ids 0x0906–0x090C (noted, unclaimed) + GeomObject 0x0900 and 0x08FE GeomBuffers (poly buffers: 0x0100 vertices / 0x010A edges / 0x011A faces) | typed — PMBS_GEOM_BUFFERS_PARSE=1 since §10j; consumers via CGeomBuffers accessors |
| Part F (Editable Mesh stream) | 0x3001 (+0x2845/6/7), {0x3003+0x3004}×n, 0x3002, 0x4020–0x403B | the known-unknown sequence UPDATE1::CEditableMesh::parse drains (update1/editable_mesh.cpp) |
Part F <TriObject base mesh data> (Mesh) |
TriObject ids 0x0901–0x0904 (noted, unclaimed) + 0x0900/0x08FE (tri buffers: 0x0914 vertices / 0x0912 faces / 0x0916+0x0918, 0x0938+0x0942) | typed GeomBuffers leaves (same as Part C poly path) |
| Parts D, E, G, H (Poly Select / Edit Poly / Edit Normals / paint) | modifier scene classes | untyped pass-through (registered unknown superclasses) |
| Part I (materials/texmaps) | material scene classes | typed — CParamBlock2/CMtlBase/CMultiMtl (pipeline_max/builtin/) since the §10j/§10k design-doc sessions; see design doc §10j for the decode |
| Part J (Biped system object) | the 0x9155 Biped scene object's structure/pose/keytrack chunks |
untyped pass-through in pipeline_max proper (registered as an unknown-Object fallback); read-only typed access lives in pipeline_max_export_common/biped_rig.{h,cpp} and pipeline_max_export_anim/biped_anim.{h,cpp}, which peek the chunks by id without claiming them (so raw bytes stay authoritative and roundtrip is unaffected by construction) — same discipline as CMtlBase::decodeName, just not yet promoted to a CSceneClass subtype |
| Part K (NeL scripted extends / PACS) | scripted-plugin ParamBlock2 + PACS output formats | export-side readers in pacs_prim / shape (water, flare, …); see design §10v/§10z-bis |
| Part L (Edit Mesh mod-app) | 0x2500 → 0x2512 → 0x4000 MeshDelta records |
decoded — shared EDITMESH (pipeline_max_export_common/edit_mesh_mod) driving ig + cmb + shape |
| Part M (Physique mod-app) | 0x2500 → 0x2512 → 0x2504 → 0x2506 → 0x0989 |
decoded — shared PHYSIQUESKIN for shape (and ready for .clod) |
| Part N (Shape / SplineShape) | BezierShape knot streams 0x2900/0x290a … |
decoded — shared SPLINESHAPE + SPLINEMESH (remanence + spline caps) |
| Part O (UVW Map) | old ParamBlock UVWMAP_* + gizmo PRS + mod-app 0x2510 |
planar production — shared UVWMAP; non-planar gated (PMB_UVW_APPLY) |
| Part P (Map Extender) | plugin empty 0x39bf; mod-app 0x2500 → 0x2512 → 0x03e8.. |
cache-decode production — shared MAPEXT (map_extender_mod); no plugin settings to reimplement |
All payloads live in a nestable tagged-chunk stream. Each chunk records a 16-bit ID and a byte length (framing is provided by the container; a reader can always determine a chunk's payload size and skip it). Chunks may nest. Chunk IDs are scoped to their owning object type and nesting level — identical numeric IDs recur with unrelated meanings; a chunk's meaning is determined by (owner class, nesting context, ID), never by ID alone.
Two parser disciplines occur, and each stream below is labeled with one:
All scalars are native little-endian x86/x64 Windows encodings, written raw with no alignment or endianness conversion:
| Type | Size | Notes |
|---|---|---|
int32 / BOOL / DWORD / uint32 / TimeValue |
4 | BOOL stores 0/1 |
uint16 |
2 | |
uint8 / bool8 |
1 | C++ bool |
float32 |
4 | IEEE-754 |
Point3 |
12 | three consecutive float32 (x,y,z) |
| wide string | var | SDK wide-string chunk encoding (external) |
int and DWORD remain 4 bytes on 64-bit builds, so 32- and 64-bit writers agree. Some blocks are raw struct dumps and additionally depend on MSVC struct padding — flagged individually.
This is the format most relevant to exporting Ryzom Core assets.
Chunk 0x08FD (2301) "validity":
uint32 rpoVersion ← RPO_SERIALIZE_VERSION; always 0 (SOURCE-VERIFIED)
<RPatchMesh blob> ← §A.2, raw sequential, inline;
carries its OWN leading version field (1–9)
<PatchMesh> ← PatchMesh stream (external)
<Mesh> ← Mesh stream (external);
cached triangulation of the patch
There are two version fields: the outer rpoVersion (a validity gate, RPO_SERIALIZE_VERSION = 0 — the only value ever written; the blob is meaningful only under it — SOURCE-VERIFIED, RPO::Load) and the blob's own internal format version (1–9, §A.2). The PatchMesh and Mesh streams follow unconditionally.
Files carry the layout of the plugin build that last saved them; the current writer emits v9. Versions 1–9 are the only ones that exist (SOURCE-VERIFIED, RPatchMesh::Load).
v9 layout:
uint32 version
int32 patchCount
patchCount × PatchRecord:
int32 log2TilesU ← tile columns = 1 << log2TilesU
int32 log2TilesV ← tile rows = 1 << log2TilesV
int32 tileCount ← = (1<<log2TilesU) * (1<<log2TilesV)
tileCount × TileRecord:
uint16 tileNum
uint16 tileFlags
uint8 displaceNoise ← v9 only; see version table
3 × LayerRecord:
bool8 reserved ← always written 0; ignore on read
int32 tileId
int32 rotation
int32 colorCount ← = ((1<<log2TilesU)+1) * ((1<<log2TilesV)+1)
colorCount × uint32 vertexColor ← 0x00RRGGBB
4 × uint32 edgeFlags ← per patch edge (v7+; see table)
int32 vertexCount
vertexCount × BindRecord:
bool8 isBound
int32 bindType ← enum, see below
uint32 edgeIndex ← 0..3, edge of target patch
uint32 patchIndex ← target patch
uint32 before, before2 ← rebuildable cache indices
uint32 after, after2 ← "
uint32 tangent ← "
int32 bindType2 ← duplicate of bindType (same value
written twice)
uint32 primaryVertex ← primary vertex of the bind group
int32 tileTessLevel ← rTess.TileTesselLevel (int)
bool8 modeTile ← rTess.ModeTile (bool, 1 byte)
bool8 keepMapping ← rTess.KeepMapping (bool, 1 byte)
int32 transitionType ← always written; original loader consumes it
only when version == 4 — see A.4
int32 selLevel ← always written; see A.4
(Trailer field widths SOURCE-VERIFIED from the member declarations in nel_patch_mesh.h and the raw sizeof-based writes.)
Bind type enum (SOURCE-VERIFIED, nel_patch_mesh.h): enum typeBind { BIND_25=0, BIND_75=1, BIND_50=2, BIND_SINGLE=3, BIND_COUNT, BIND_ALIGN=0xffffffff }, i.e. bind fractions in order 25%, 75%, 50%, single. Width is that of a C enum (4 bytes; the BIND_ALIGN member forces it).
Cache fields (before/after/tangent) are persisted but fully reconstructable from topology — semantically redundant.
| Field group | v1–v2 | v3–v4 | v5–v6 | v7–v8 | v9 |
|---|---|---|---|---|---|
| Tile record | two int32 per tile, read and discarded; tiles empty |
uint16 num + 3 layers; flags forced 0; noise randomized on load |
+ uint16 flags |
(unchanged) | + uint8 noise |
| Vertex colors | present, overwritten to white (≤v5) | same | v5 white / v6 kept | kept | kept |
| Edge flags | absent | absent | absent | present (4×uint32) |
present |
| Bind records | identical in all versions | ||||
| Trailer (as consumed by the original reader) | tessLevel, modeTile, keepMapping, selLevel | same, + transitionType inserted before selLevel when version == 4 only | same as v1–v2 | same | same |
(SOURCE-VERIFIED: selLevel is read unconditionally for every version — it is not a v9 addition; transitionType is consumed only on the version == 4 path. See A.4 for why the reader's trailer does not match the writer's.)
Pre-v9 files store no noise byte at all; the original tool invents one randomly at load — tile displacement noise is simply not part of the format before v9.
The original reader consumes transitionType only when version == 4, while the writer emits it for every version (SOURCE-VERIFIED: RPatchMesh::Save writes tessLevel, modeTile, keepMapping, transitionType, selLevel unconditionally; RPatchMesh::Load reads transitionType only inside if (nVersion == RPATCHMESH_SERIALIZE_VERSION_4)). Consequences:
keepMapping, in the order transitionType, selLevel. This is the authoritative on-disk layout.selLevel ← transitionType's bytes and leaves the final 4 bytes (the real selLevel) unconsumed — absorbed silently by chunk framing at CloseChunk. Both fields are UI state, so the bug is cosmetic in the original tool, but it means the original loader is not the authority on the trailer layout; the writer is.The <PatchMesh> stream that follows the 0x08FD chunk is a flat chunk list (Max core PatchMesh serializer). Ids observed across the whole zone corpus, all decoded by NELPATCH::decodePatchMesh:
0x0BD6/0x0BC2/0x0BD1/0x0BEA int32 counts: verts / vecs / edges / patches
0x0BE0 (× numVerts) vertex container: 0x03E8 Point3 pos; 0x03FC int32 flags
(bit 0 = PVERT_COPLANAR); 0x0406/0x0410/0x041A counted
int32 lists (vectors / edges / patches attached)
0x0BCC (× numVecs) vector container: 0x03E8 Point3 pos; 0x03FC int32 flags;
0x0410 int32 owner vert; 0x0406 counted int32 list
0x0BD2 (× numEdges) edge container: 0x03E8 int32×4 (v1, vec12, vec21, v2);
0x03F2 counted int32 patch list
0x0BF4 (× numPatches) patch container: 0x0424 int32 type (4 = quad); 0x03E8 int32
numVerts; 0x03F2 int32 v[4]; 0x03FC int32 vec[8]; 0x0406
int32 interior[4]; 0x0410 int32 smGroup; 0x041A int32 flags
(bit 0 = PATCH_AUTO interiors); 0x042E int32 edge[4]
0x0BB8, 0x0BB9, 0x0BFE, 0x0C12–0x0D7A, 0x3440 UI/aux state — preserved raw
The sub-chunk sets are uniform corpus-wide (count-verified against the 0x0BD6-family counts on parse). The <Mesh> stream after it (ids 0x0906–0x2398 family) is the cached triangulation — pass-through raw; the zone exporter never reads it.
Chunk ids and payloads in this part are SOURCE-VERIFIED against nel_patch_edit/np_epm_file.cpp (B.1), np_edit_patch_data.cpp (B.2, B.4), and np_ep_vert_mapper.cpp (B.3).
| ID | Payload | Semantics |
|---|---|---|
| 0x1100 | empty | presence ⇒ named-selection sets need no fixup (newer files); absence ⇒ legacy fixup |
| 0x1001 | int32 |
subobject level (object/vertex/edge/patch/tile) |
| 0x1010 | BOOL |
display lattice |
| 0x1020 | BOOL |
display surface |
| 0x1040 | int32 meshSteps; BOOL adaptive (always 0, reserved) |
viewport tessellation |
| 0x10E0 | int32 |
render steps (valid 0–100) |
| 0x10F0 | BOOL |
show interior edges |
| 0x1090 / 0x10A0 / 0x10B0 | TessApprox (external SDK format) | view / production / displacement tessellation |
| 0x2000 | tileMode; tileLevel; keepMapping (member-sized raw fields) | Ryzom tiling globals |
| 0x2002 | includeMeshes | Ryzom |
| 0x2001 | transitionType | Ryzom |
| 0x10C0 | BOOL viewTessNormals; BOOL prodTessNormals |
|
| 0x10D0 | BOOL viewTessWeld; BOOL prodTessWeld |
|
| 0x1050 / 0x1060 / 0x1070 | container | named-set names for vertex / edge / patch level; contains repeated 0x1080 wide-string subchunks, one per set name |
| 0x1000 | int16 |
legacy read-only: old selection level; 1 → patch, 3 → vertex |
| 0x1030 | — | legacy display-verts chunk (DISP_VERTS_CHUNK); the original loader ignores the payload and only calls SetObsolete() — obsolete-file indicator |
Note the 0x2000-range IDs here are unrelated to the 0x4000-range IDs in §B.2 despite identical meaning.
All wrapped in a single chunk 0x1000; its children (DISPATCH):
| ID | Payload |
|---|---|
| 0x1015 | DWORD flags |
| 0x1020 | int32 meshSteps; BOOL adaptive (0) |
| 0x1150 | int32 renderSteps (valid 0–100) |
| 0x1160 | BOOL showInterior |
| 0x1070 / 0x1080 / 0x1090 | TessApprox view / prod / disp (external) |
| 0x1030 | BOOL displaySurface; BOOL displayLattice |
| 0x1110 | BOOL viewTessNormals; BOOL prodTessNormals |
| 0x1120 | BOOL viewTessWeld; BOOL prodTessWeld |
| 0x4000 | tileMode; tileLevel; keepMapping |
| 0x4003 | includeMeshes |
| 0x4002 | transitionType |
| 0x1040 / 0x1050 / 0x1060 | named-set lists (vertex/edge/patch), external list format; only if non-empty |
| 0x1130 | vertex mapper — §B.3 |
| 0x1140 | PatchMesh (external) — the fully edited result patch |
| 0x4001 | RPatchMesh blob — the complete §A.2 format, nested here |
Legacy pre-R3 files instead contain an edit-history record list (per-operation records replayed at load). The record chunk IDs/payloads are not covered here; for asset extraction you can treat any file lacking 0x1140/0x4001 inside the local data as requiring the legacy path, which is out of scope. For exporting geometry, chunks 0x1140 + 0x4001 are the ones you want: they hold the final patch and its Ryzom tile/bind data.
One child chunk 0x1000:
int32 vertCount
vertCount × MapVertRecord ← raw struct dump
int32 vecCount
vecCount × MapVertRecord ← raw struct dump
MapVertRecord is the raw EPMapVert struct dump in declaration order (editpat.h; CORPUS-VERIFIED 2026-07-06 — an earlier revision of this page had the field order wrong): BOOL originalStored, int32 vert (the index in the FINAL patch this input point maps to; −1 = unmapped), Point3 original, Point3 delta — 32 bytes, no padding on the shipping compiler, but the on-disk size is compiler-defined and unversioned (SOURCE-VERIFIED: written as a raw sizeof(EPMapVert) * count memory dump, both tables). Validate against chunk length: record size = (chunkLen − 8) / (vertCount + vecCount). Record index = position in the modifier's INPUT patch; vert = index in the stored final patch.
Apply semantics (EPVertMapper::UpdateAndApplyDeltas, np_ep_vert_mapper.cpp): a refresh pass first sets original ← input[i], originalStored ← TRUE for every mapped record with i inside the input's counts; the apply pass then writes final[vert] = original + delta (i.e. input + delta) for records with vert ≥ 0 && originalStored. So the stored original/originalStored bytes only govern records past the input's counts (those write the zero point); stored original values are routinely stale garbage and must not be trusted. When the base object is unchanged since the file was saved, input + delta reproduces the stored final patch bit-exactly — the delta application is what makes the modifier robust against upstream edits, and replicating it (not copying the stored final) is what matches the reference exports (CORPUS-VERIFIED over the 1201-file zone corpus).
A delta wrapper chunk 0x1000 contains three sibling chunks:
| ID | Payload |
|---|---|
| 0x1000 | int32 n; n × Point3 — additive per-vertex position deltas |
| 0x1020 | int32 n; n × int32 — per-vertex flag masks, applied by XOR (coplanar bit) |
| 0x1010 | int32 n; n × Point3 — additive per-tangent-handle deltas |
Counts are independent and normally equal the patch's vertex/vertex/vector counts. This structure serves the legacy record system and is not needed for exporting current-format files.
Stream layout, exact order required:
[0x4039] paint-handler payload (§H.1) always present in current files
[0x403A] paint-host payload (§H.2) always present in current files
0–3 pairs, one per non-empty named-set level j ∈ {0=vertex,1=edge,2=face}:
[0x3003] int32 j
[0x3004] named-set list (external format) must immediately follow its 0x3003
[0x3002] int32 vertexControllerCount only if any animated vertices
[0x4038] DWORD subobjectLevel
<PolyObject base data> external; includes the MNMesh
Rules:
pipeline_max correspondence: this stream — same ids, same order — is exactly the known-unknown sequence EPOLY::CEditablePoly::parse (epoly/editable_poly.cpp) drains and re-emits verbatim, which corpus-corroborates the layout above. The <PolyObject base data> that follows appears in the same scene-object container as the noted-but-unclaimed PolyObject ids 0x0906–0x090C plus GeomObject 0x0900 and 0x08FE GeomBuffers, whose poly buffers (0x0100 vertices / 0x010A edges / 0x011A faces incl. material, smoothing, triangulation cuts) are the MNMesh payload this page defers to — typed serializers exist in builtin/storage/geom_buffers.cpp but are compiled out pending the corpus gate (Pipeline Max Design §7).
Selection-level values (explicit): 0 object, 1 vertex, 2 edge, 3 border, 4 face, 5 element. Named-set levels: 0 vertex, 1 edge, 2 face; border shares edge sets, element shares face sets.
| ID | Payload |
|---|---|
| 0x0100 | DWORD selectionLevel (0–5) |
| 0x0120 | DWORD flags; bit 0x0001 = "show end result" toggle |
| 0x2805 | container: vertex-level set names |
| 0x2807 | container: edge-level set names |
| 0x2806 | container: face-level set names |
| 0x2848 | paint-handler payload (§H.1) |
⚠ Note the numeric inversion: face container (0x2806) < edge container (0x2807). Same inversion in §D.2.
Inside each name container (ORDERED pairing within a DISPATCH loop): repeated per set —
| ID | Payload |
|---|---|
| 0x2809 | wide string: set name; on read also assigns a provisional sequential ID (0,1,2,…) |
| 0x2810 | DWORD set ID; overrides the provisional ID of the most recently read name |
A name chunk must precede its ID chunk; the ID chunk is per-set optional (legacy files keep sequential IDs).
| ID | Payload |
|---|---|
| 0x0200 | BitArray (external): vertex selection — always present |
| 0x0210 | BitArray: face selection — always present |
| 0x0220 | BitArray: edge selection — always present |
| 0x2845 | named-set list, vertex level (only if non-empty) |
| 0x2847 | named-set list, edge level (only if non-empty) |
| 0x2846 | named-set list, face level (only if non-empty) |
| 0x2849 | paint-host payload (§H.2) — always present |
Bit-array sizes are not tied to a stored mesh size and may differ from the current mesh's component counts.
Name/ID split: set names live in the modifier stream, set bit-arrays live per node, joined by the DWORD ID — never by index or name. The two lists may drift (counts and order can differ); the ID is the only valid join key.
| ID | Payload |
|---|---|
| 0x0080 | modifier base data (external; includes the parameter block: animation, current-operation selector, slice plane, master point controller, per-vertex controllers) |
| 0x0100 | raw subobject level (same 6-level enum family as Part C) |
| 0x0110 | preserve-UVs per-map-channel bit set (external map-bit-array format) |
| 0x0120 | paint-handler state (external/§H family; format owned by the paint system) |
| 0x0138 | int32 n — count of per-vertex point-controller range boundaries |
| 0x0130 | n × int32 — the boundaries (must follow 0x0138) |
| 0x0148 | int32 n — count of node-name entries |
| 0x0140 | one wide string per chunk, repeated; associated to slots purely by encounter order after 0x0148 |
The last four are present only when animated vertex transforms exist.
| ID | Payload |
|---|---|
| 0x0200 / 0x0210 / 0x0220 | BitArray: vertex / edge / face selection |
| 0x0224 / 0x0228 | BitArray: vertex hide / face hide |
| 0x0240 | paint-host + named-set state (external paint format) |
| 0x0230 | int32 operation ID — starts an operation-history triple |
| 0x0234 | operation payload (§E.3); belongs to the immediately preceding 0x0230 |
| 0x0238 | operation local data (§E.4); optional; belongs to the same triple |
| 0x0260 | int32 — ID of the operation currently being edited |
| 0x0268 | local data for that current operation |
Ordering rule: the triple 0x0230 → 0x0234 → [0x0238] is adjacent and in that order, repeated once per committed operation in application order. 0x0260 precedes 0x0268. Unknown operation IDs are fatal to the original loader (no fallback), so files in the wild only carry the documented ops of their writing era.
One wrapper chunk 0x0600 ("basics"), plus optional per-type sibling chunks. Inside 0x0600 (DISPATCH):
| ID | Payload |
|---|---|
| 0x0400 | BitArray — captured subobject selection |
| 0x0410 | int32 paramID + raw parameter bytes; repeated once per parameter. Byte length is parameter-defined: 4 for int/float/world/angle, 12 for Point3, 64 for a raw 4×4×4-float matrix. |
| 0x0420 | Matrix3 (external) — transform (only if the op has one) |
| 0x0430 | Matrix3 — mod-context TM |
| 0x0440 | map-bit-array — preserve-map settings |
Unknown parameter IDs abort the load in the original, except five reserved IDs that are skipped.
Per-type sibling chunks next to 0x0600:
| Op type | ID | Payload |
|---|---|---|
| detach vertex / detach face | 0x0601 | bool8 — detach-as-clone |
| remove edge | 0x1601 | bool8 — ctrl-key (also remove verts) |
| extrude along spline | 0x0644 | Matrix3 — spline node TM at capture |
| extrude along spline | 0x0640 | Spline3D (external) — captured spline |
Extrude-along-spline also carries a world-to-object matrix as a parameter (0x0410 with 64-byte raw payload).
Each op type has its own child-chunk vocabulary. "count+array" = int32 n then n elements in the same chunk.
| Op | ID | Payload |
|---|---|---|
| create | 0x0600 | count+array of Point3 — new vertex positions |
| 0x0608 | int32 — total new-face count |
|
| 0x0610 | repeated per face: int32 faceIndex, int32 degree, degree × int32 vertex indices |
|
| attach | 0x0E04 | int32 n — number of attach slots |
| 0x0E08 | int32 which — slot selector for the next mesh chunk |
|
| 0x0E00 | MNMesh (external) — attached mesh, into slot which |
|
| 0x0E0C | n × int32 — per-attach material-ID modulus |
|
| cut | 0x0840 | int32 n; n×int32 startLevel; n×int32 startIndex; n×Point3 start; n×Point3 end; n×Point3 normal |
| target weld (vert & edge) | 0x06C0 | count+array int32 — source indices |
| 0x06C4 | count+array int32 — target indices |
|
| create edge | 0x0640 | count+array int32 — flattened (v1,v2) pairs |
| edit triangulation | 0x0680 | count+array int32 — flattened diagonal pairs |
| turn edge | 0x06F0 | count+array int32 — diagonal indices |
| 0x06F2 | count+array int32 — parallel face indices |
|
| insert vertex (edge) | 0x1600 | int32 n; n×int32 edge index; n×float32 parametric position |
| insert vertex (face) | 0x1608 | int32 n — allocation count |
| 0x1610 | repeated: int32 which, int32 face, int32 numBary, numBary×float32 |
|
| hinge from edge | 0x32F0 | int32 — hinge edge index |
| transform | 0x12C3 | int32 n |
| 0x12C4 | n × Point3 — per-vertex offsets (0x12C3 must precede) |
|
| 0x12C5 | wide string — node name |
ID collisions across ops (e.g. 0x0600, 0x0640) are safe because each payload is parsed only in its own op context.
Write order (each chunk optional on read; sequence fixed):
optional legacy block:
[0x3001] container — pre-R3 named sets:
[0x2845] vertex sets \ each contains repeated pairs:
[0x2846] face sets > [0x2849] BitArray (external)
[0x2847] edge sets / [0x2850] wide string name
(inner containers optional, any order; set/name pairing is by arrival order;
IDs are synthesized sequentially on migration: set i → ID i)
0–3 modern named-set pairs:
[0x3003] int32 levelIndex (0..2)
[0x3004] named-set list (external)
[0x3002] int32 vertexControllerCount
[0x4020] BOOL affectRegion
[0x4024] float32 falloff (written under a 4-byte int size; payload is float bits)
[0x4025] float32 pinch (−10..10)
[0x4026] float32 bubble (−10..10)
[0x402C] BOOL useEdgeDistance
[0x402D] int32 edgeIterations (1..999)
[0x4030] BOOL ignoreBackfacing (inverted sense vs. UI "affect backfacing")
[0x4034] DWORD objectFlags (bit meanings mostly external; one bit = show-end-result)
[0x4038] DWORD subobjectLevel (0 obj, 1 vert, 2 edge, 3 face, 4 polygon, 5 element)
[0x403B] int32 referenceLayoutVersion (always 1 in current files)
<TriObject base mesh data> external
Reader notes:
UPDATE1::CEditableMesh::parse (update1/editable_mesh.cpp), written against real files, instead drains them as siblings immediately following 0x3001 at the same level. Both readings survive the roundtrip harness (unknown chunks pass through verbatim either way, nested or not), so the corpus gate cannot discriminate — hex-inspect an actual legacy-block file before typing this structure.numFaces × 3, indexed face*3 + side. Vertex sets size numVerts, face sets numFaces.pipeline_max correspondence: the sequence above — same ids, same order, including every 0x40xx trailer chunk through 0x403B — is exactly the known-unknown sequence UPDATE1::CEditableMesh::parse drains and re-emits verbatim, corpus-corroborating the layout. The <TriObject base mesh data> corresponds to the noted-but-unclaimed TriObject ids 0x0901–0x0904 plus GeomObject 0x0900 and 0x08FE GeomBuffers (tri buffers: 0x0914 vertices / 0x0912 CGeomTriIndexInfo faces incl. smoothing groups / 0x0916+0x0918 and 0x0938+0x0942 secondary pairs) — see Pipeline Max Design §7.
Identity: Class ID (0x4AA52AE3, 0x35CA1CDE), object-space modifier. One reference (the parameter block; external encoding). Pblock parameters for completeness: select-by mode (int 0–3), ignore-backfacing (BOOL), show-handles (BOOL), display-length (float, default 10).
| ID | Payload |
|---|---|
| 0x0100 | int32 — selection level (object / normal / vertex / edge / face; numeric values external, verify) |
| 0x0140 | container — named-set names; children (positional pairing): repeated { 0x0144 wide-string name → 0x0148 DWORD id }. Name must precede its ID; ID per-set optional (sequential provisional IDs otherwise). |
| ID | Payload |
|---|---|
| 0x0200 | named-set list (external) — per-set bit-arrays keyed by DWORD ID |
| 0x0240 | poly-mesh normal spec (external) — only for poly meshes |
| 0x0250 | tri-mesh normal spec (external) — only for tri meshes; mutually exclusive with 0x0240 |
| 0x0260 | DWORD flags — only bit 0x08 ("tri-mesh mode") is durable; bits 0x01/0x02/0x04 are transient undo markers with no durable meaning |
Cached meshes, validity intervals, and in-progress buffers are never persisted.
These payloads appear inside different container IDs per owner (Editable Poly: 0x4039/0x403A; Poly Select: 0x2848/0x2849) but have identical contents. Their internal chunk IDs are 0x0000 and 0x0001 (sequential-from-zero enums), safe only because of nesting.
| ID | Payload |
|---|---|
| 0x0000 | BOOL — painted soft-selection active |
| 0x0001 | BOOL — paint deformation active |
Both optional; default false.
| ID | Payload |
|---|---|
| 0x0000 | float32[N] — soft-selection weights, one per vertex in vertex order; N = chunkLength / 4 (no explicit count) |
| 0x0001 | Point3[M] — deformation offsets (object-space deltas); M = chunkLength / 12 |
Each written only if its count > 0. Weights nominally 0..1. Writers emit exactly the logical count, which may be smaller than a runtime buffer.
Status in pipeline_max: all material and texmap classes are currently untyped pass-through (the builtin material stubs — CMtlBase, CMtl, CStdMat, CStdMat2, CTexmap, CBitmapTex, … — are empty and unregistered). This part is the forward reference for Pipeline Max Design §12.5 milestone (g); every mapping below still has to pass the §12.2 corpus gate before it counts as established. Note also that for NeL export most material properties of interest come from the NeL AppData entries (design doc §8) and the standard-material ParamBlock2s, not from these legacy chunks.
Universal rule: chunk 0x4000 in every material/texmap wraps the shared material-base payload (name, G-buffer channel, base flags — external format). All streams here are DISPATCH (free sibling order). Three payload kinds recur:
Current files write only 0x4000; everything else lives in a ParamBlock2 (external). Legacy read-only chunks:
| ID | Kind | Meaning |
|---|---|---|
| 0x5002 | data int32 |
filter type |
| 0x5003 | data int32 |
alpha source: {0 file, 2 rgb, 3 none}. Note: the ParamBlock2 layer re-encodes these as 0/1/2. |
| 0x5004 | data wstring | legacy bitmap filename |
| 0x5010 | data | bitmap-info block (external); start/end frame forced to "undefined" after read |
| 0x5011 | data TimeValue |
start time |
| 0x5012 | data float32 |
playback rate |
| 0x5013 | presence | alpha-as-mono = true |
| 0x5014 | data int32 |
end condition |
| 0x5016 | presence | alpha-as-RGB = true |
| 0x5021 | presence | very-old-file marker |
| 0x5030 | presence | premultiplied alpha = false |
| 0x5040 | presence | apply crop = true |
| 0x5050 | presence | place-image mode = true |
| 0x5060 | presence | jitter placement = true |
| 0x5001, 0x5022 | — | defined but never handled; dead |
Any 0x50xx chunk ⇒ legacy file ⇒ values migrate into the param block after load.
| ID | Kind | Payload |
|---|---|---|
| 0x4000 | data | material base |
| 0x5000 | data ULONG |
flags |
| 0x5004 | data int32 |
shading model (enum; Phong default) |
| 0x5300 | data int32 |
version (current 9) |
| 0x5002 | data ULONG |
per-map enable bitmask (bit i = map i on) |
| 0x5400 | presence | dim-reflect = true |
| 0x5100–0x510A | data float32 |
per-map amount (one chunk per map slot; written only when ≠ 1.0) |
Files with version < 9 were migrated at load (map-amount blending, soften level); the stored values are the pre-migration ones.
Save: 0x4000 base; 0x5300 int32 version (current 13); 0x5600 exposure-control payload (external). Load additionally accepts all §I.2 legacy chunks (0x5000/0x5004/0x5002/0x5400). Reserved IDs: 0x5500, 0x5510, 0x5511. Versions ≤ 10 stored parameters in an old flat block redistributed at load; 11–12 had a bump-scale fixup. Reference-slot order (for scene-graph remapping): old-pblock, texmaps, shader, shader-pblock, extended-pblock, sampling-pblock, maps-pblock, dynamics-pblock, sampler.
Texmap sub-container vocabulary (owned by the maps container, referenced by both variants): 0x5002 presence ⇒ obsolete file; 0x5003 data ULONG enable bitmask; 0x5100–0x510A per-map amount.
New namespace: 0x1000 = header (material base), 0x1001 = int32 fileVersion. Compatibility namespace: 0x4000 = old header, 0x0010 = int32 legacy sub-tex count. File versions: 0 = 2008-era, 1 = beta, 2 = 2009+. Collision hazard: new header 0x1000 equals the old map-offset base, and old header 0x4000 equals the universal base-header ID; disambiguation is stateful (first header chunk seen wins, and old/new headers are mutually exclusive). Old versions store maps/masks as direct references restructured into a param block on load; opacity was once integer, now float ×1.
0x4000 base; 0x1010 int32 version (current 2). Version 1 files carry one extra leading slot in each of three tab parameters (type/enable/amount) that must be dropped (delete index 0, shrinking from max-slots to max-slots−1). ID 0x1000 reserved (map-off range).
| ID | Kind | Payload |
|---|---|---|
| 0x4000 | data | base |
| 0x4005 | marker | "new sparse array" layout; implies 0x4003 semantics too |
| 0x4002 | data int32 |
sub-material count; sizes name/enable tables |
| 0x4001 | marker | obsolete layout; implies 0x4002 follows semantically (old writer fall-through) |
| 0x4010 | data | name table (external) |
| 0x4003 | marker | "ParamBlock2 present" |
| 0x1000–0x1FFF | presence range | id − 0x1000 = sub-material index whose enable flag is FALSE |
Discriminators: no 0x4003 ⇒ oldest layout; no 0x4005 ⇒ old dense-array layout; both trigger table rebuild/reindex at load.
0x4000 base; 0x1010 marker "ParamBlock2 present". Legacy: 0x1000/0x1001 presence ⇒ facing/back material disabled; old files also rescale translucency ×100 into the param block.
0x4000 base. 0x5000 (presence: use-UVW) is written by old files but ignored on read in current code — state lives in the param block.
| ID | Distinct meanings |
|---|---|
| 0x1000 | multi map-off base / double-sided material-off base / composite-map new header / composite-material map-off / patch local-data wrapper / vertex-mapper data / point-delta table … |
| 0x1010 | double-sided marker / composite-material version |
| 0x4000 | universal material header and composite-map legacy header |
| 0x5000 | std-material flags / vertex-color use-UVW |
| 0x5002 | std-material map bitmask / texmap-container obsolete marker |
Provenance — different from every other part above. Autodesk character studio (biped.dlc / physique.dlm) is a third-party plugin; no source is available at all, in-tree or otherwise. Everything below comes from black-box corpus analysis (169 biped skeleton files + 4452 biped anim files) cross-validated against direct-reference .skel/.anim exports, plus targeted Max 9 differential datasets (biped_dataset_gen.ms/biped_anim_dataset*_gen.ms — one structural param or one figure/animation edit per generated file, with biped.getTransform world-space ground truth logged per bone). Treat every row as "reproduces the corpus to the stated tolerance", not "read from a spec" — the §12.2 corpus-gate discipline applies at least as strictly here as anywhere else in this document. The full derivation narrative (how each row was found, what was tried and ruled out) lives in Pipeline Max Design §10 and §10c–§10q; this Part is the condensed chunk-map reference, kept in sync with it.
Scope. The Biped scene object, ClassId {0x9155, 0}, superclass 0x60 (Object) — the root of a character-studio rig, one per biped skeleton (two for the rare multi-biped file, e.g. tr_mo_kitin_queen). Every non-COM biped bone's TM controller is a separate scene object, BipDriven Control ({0x9154, 0}, typed as BIPED::CBipedDriven — see design doc §7), which stores only a (bone_id, link_index) pair (chunk 0x0200) identifying which slot of the system object's tables drives that bone; the actual pose data lives entirely on the system object below.
The biped's visible geometry is NOT stored (2026-07-08). Each bone's Biped Object ({0x9125, 0}, superclass 0x10, the per-part display geometry) is a 56-byte parametric stub — chunk 0x2602 (24 B: two cross-section floats, the bone's internal id at [3], two flag ints) + 0x2603 (int32 = the rig's bodyType, matching 0x0115) — and the visible mesh is regenerated by the plugin at load from bodyType + the J.2 figure records. For the corpus's uniform bodyType (3 = Classic) that mesh is literally boxes — each part a cuboid derived from its link length and the stored cross-section — so there is nothing to store. Measured corpus-wide: a 150-file random scan of the biped anim corpus found no Biped Object instance over 56 bytes, so no corpus asset carries edited/collapsed biped-part geometry, and the remaining unknown 0x9155 chunks (~1.7 KB/file total, the largest of which correlates with animation, J.5) are far too small to be mesh payload — the "biped mesh is the missing bytes" hypothesis is ruled out for this corpus. Where an interactive edit-and-collapse of a biped part's geometry WOULD land (a grown 0x2602-family, a per-node Edit Mesh stack per Part F, or a 0x9155 chunk) has not been probed — one interactively-authored file through the --diff-rig channel would settle it if that workflow ever matters. All streams here are DISPATCH (the object carries thousands of chunks — undo buffers, UI state, per-key IK pivot arrays, etc. — the vast majority still unidentified and riding raw pass-through; the ids below are the ones a consumer needs).
| ID | Kind | Payload | Meaning |
|---|---|---|---|
| 0x0104 | data, Y-up 4×4 | float32[16] |
Current-position COM world matrix — the animation-mode baseline, NOT the figure pose (that's 0x006c). Updated by any ordinary (non-figure) move; a Figure-Mode move updates it and 0x006c together; "Clear All Animation" overwrites it with the figure value (the only operation that does). On a never-repositioned rig it coincides with the figure pose ≈ (0,0,height), which is what earlier read this as "figure-mode". [13] = the up-translation = the unkeyed-COM vertical baseline (design doc §10o). |
| 0x0107 | data | 2× int32 |
Plugin build-id pair; constant (15780518, 12779458) across the whole corpus — a coarse "same compiled version" fingerprint, not itself load-bearing. |
| 0x0115 | data int32 |
bodyType | The biped bodyType enum, 0=Skeleton, 1=Male, 2=Female, 3=Classic (MAXScript-doc values; probe-proven by a 3→1 edit writing exactly 1). Reads 3 = Classic (the cube-parts body) on every legacy corpus file and 0 = Skeleton (the documented createNew default) on figures created fresh in Max 9+ (all differential-dataset files, plus the rebuilt tr_mo_kitin_queen) — so the fresh-vs-legacy decode gate keyed on this value (design doc §10e, SBipedRig::BodyType, formerly FigureVersion) is really "authored-as-Classic vs left-at-Skeleton-default", i.e. authoring era by proxy, and stays correct as such. |
| 0x0012 | data int32 |
dynamicsType | 0 = Biped Dynamics, 1 = Spline Dynamics. Exactly 64 corpus anim files carry 1 — almost entirely the strafe/walk-backward locomotion class (§10n "Twelfth"); plays no role in any decode decision so far. |
| 0x0117 | data, Y-up 4×4 | float32[16] |
Move All Mode reference-frame transform (row-major affine): translation row [12,13,14] = (x, z_up, −y) → NeL (m12, −m14, m13), rotation in the 3×3. Identity on every shipped corpus root rig (Move All was never used in the reference assets); applied by the exporter as a final world-space COM offset (§10o — decoded via three interactive differential probes). On LINKED rigs it is NOT identity (real rotations/translations on the kitin/queen Bip02 systems; the synthetic link probes record the parent's key-range displacement in it) — not part of the J.4-bis linked-COM decode, linked-rig semantics unmapped (§10m-quater). |
| 0x0112 | data | float32[12] (3×3 rows + translation) |
Linked-COM parent snapshot: the inverse of the parent's world TM captured when the link was last established/edited, in PLAIN world coordinates (not Y-up). Identity on unlinked/root rigs. See J.4-bis for the full linked-COM local decode (§10m-quater; was a §10p unknown). |
| 0x0100 | data, SetNumLinks table |
ints keyed by role | Per-chain link counts: [8]=spine, [9]=tail, [17]=pony1, [22]=nFingers, … (partially mapped; only the slots actually consumed are pinned). |
| 0x0102 | data | int32 header + id list |
Sub-anim index/enable table (header count = 16, then track-id entries); grows by one entry whenever any keytrack first gains keys (§10p — moved by every keying case, no float payload). |
| 0x0110 | data | int32[] |
Per-track anim-handle/id table (two runs of dwords stepping by 11); renumbers when any chain's link count changes. Internal bookkeeping, not geometry. |
| 0x00ca | data | float32[5] |
Height-derived dynamics record: [1] = structural height, [2] ≈ height/2, [4] = gravAccel (the UI's gravitational acceleration; edit 9.916→500 moved only this float, §10p). |
| 0x015e | data | int32[9] |
Separate-tracks flags (sep arms/legs/spine/neck… one entry each; §10p f90..f93). |
| 0x014b | data | 2× int32 |
In-place mode state (§10p fA3). |
| 0x0109 | data | int32 |
Twist enable/links (§10p f97). |
| 0x0204 | data | int32 |
A link-count-dependent scalar (73 on the standard humanoid; moves with every structural link-count edit — exact meaning unpinned). |
| 0x000a / 0x001a | data | small int / zeros | Structure-presence records (tail/arms and finger/toe respectively — §10p; move only on those structural edits). |
Not stored in the file at all (probed and confirmed zero-change, §10n "Tenth"/§10p): talentFigMode, lockCom, and the adaptation locks are Character Studio session preferences — MAXScript reads them back as set, but no .max byte changes; rootName, trianglePelvis/triangleNeck, foreFeet/shortThumb/knuckles/ponytail likewise produced no 0x9155 change on the probe rig (property absent on that build, or stored elsewhere).
One record per chain, holding the rig's authored dimensions and default local pose — evaluated once (figure time), not per animation frame. Y-up throughout (position conversion (x,y,z) → (x,−z,y); rotation basis change via C = Rx(+90°), see design doc §10 item 1).
| ID | Chain | Layout |
|---|---|---|
| 0x000b | spine | [0]=?, [1..n] per-link lengths, trailing 4×4 base-attach matrix (n = floatCount − 17) |
| 0x000d | pelvis | [0..1]=?, trailing 4×4 (identity on legacy files; fresh-format files carry the last-spine-link→neck attach offset in its translation, see design doc §10e) |
| 0x000e | tail | same shape as spine |
| 0x0013 / 0x0014 | pony1 / pony2 | same shape as spine (2-link chains) |
| 0x000f | leg | per-side half (right first, see J.4): [0..9] params ([1]=thigh side-offset, pelvis-frame (0,0,±v)), [10]=nToes, then per toe [nLinks][4×4 matrix][nLinks lengths] |
| 0x0010 | arm | per-side half: [0..15] params ([7]=clavicle angle, [9,8,10]=clavicle offset (x,y,z) in the last-spine-link frame — x/y are 0 on humanoids, nonzero on monster rigs), [16]=nFingers, then per finger [nLinks][4×4][lengths] |
| 0x0064 | head | [0..3] head quat (pelvis-relative, identity component order), [7]=neck angle count, [8..] neck per-link angle triples (stale/copy — see 0x0065 below) |
| 0x0065 | neck (authoritative) | [0]=int count of angle floats (3/link), [1..count]=(a1,a2,a3) triples, trailing 4×4 base-attach matrix. Supersedes the 0x0064 copy, which goes stale on ~28% of the corpus. |
| 0x006c | COM (figure) | [0..2]=ComDisp, the current V/H/T displacement relative to the figure COM in the COM's local frame (Bip01World = figureCom + ComRot·(d0, −d2, d1)); doubles as the §10o discriminator — nonzero ⟺ a committed non-figure pose exists, so the current-position frame (0x0104/0x0260) is authoritative for an unkeyed COM vertical; exactly zero ⟺ figure height applies. [1]-analog of 0x0260's HeightCorrection slot is always 0 here. [4..6]=COM position (Y-up), [8..11]=COM world quat (Y-up; NeL convention = (−x, z, −y, w) — not always the canonical −90°Z humanoid pose, e.g. tr_mo_balduse) |
| 0x0067 / 0x0068 / 0x006d / 0x006e | spine / tail / pony1 / pony2 per-link angles | [0]=int count, then (a1,a2,a3) per link — feeds the same R = Rx(a3)·Rz(−a1)·Ry(a2) composition as the animated angle channels (J.4) |
| 0x0258–0x0261 | live-state bank | The 0x0064..0x006d pose-record family mirrored at a fixed +0x01F4 id offset (0x0258=0x0064+0x1F4, … 0x0260=0x006c+0x1F4, 0x0261=0x006d+0x1F4), identical byte sizes throughout — the animation-mode live-state twin of the figure records, not undo/UI junk (§10p). Static-chain members (head/neck/pelvis/spine/tail/pony) are byte-identical to their base records; the leg/arm members (0x025d/0x025e) differ only at ULP level (a recomputed live copy); the one genuinely divergent pair is 0x006c/0x0260 — figure vs current COM. 0x0260[0..2] = the current-position translation CORRECTION vector (Y-up; 0x0104.t + 0x0260[0..2] = the exact current-position translation — decoded as the scalar [1] alone in §10o for the unkeyed-vertical case, generalized to the full vector by the linked-COM decode §10m-quater, where it is float-exact on the kitin linked rigs); 0x0260[5] mirrors 0x0104[13]; 0x006c[1] is always 0. Consumed by the exporter since §10o (vertical) / §10m-quater (full vector, linked COMs). |
| 0x01f4–0x01fc | per-limb length tables | Absolute per-chain length/scale tables that rescale proportionally under a structural height edit (they are lengths, not multipliers — §10l/§10n). Attribution by single-variable link-count edits (§10p): 0x01f5=neck, 0x01f7=spine, 0x01f8=tail, 0x01f9=leg/toe, 0x01fa=arm/finger, 0x01f4/0x01fc=global (height-only). The suspected source of the sub-mm chain-position residues (design doc §10 open item 1); element-level layout not yet pinned. |
The "current position" family moves in lockstep (§10n "Tenth"/§10o): any ordinary move updates 0x0065, 0x0104, 0x0259, 0x0260 together (0x006c untouched); a Figure-Mode move updates all four plus 0x006c; "Clear All Animation" overwrites the family with the figure value. 0x0065/0x0259 are not independent data here — their trailing 16 floats are 0x0104's matrix verbatim behind the neck record's 4-float header (count + 3 angles), which is why they track it.
Chain-base conversions (corpus-validated): spine/tail/pony matrices use rows-as-NeL-IJK directly with local pos (m13, m12, −m14); toe-base matrices additionally z-flip the quat (x,y,−z,−w) with pos (m12, m13, −m14). Right-side toe/finger bases in the legacy format are NOT the mirror of the left half's own matrices — the right half uses a distinct, still-unrecovered basis, so the legacy decode reads the left half only and mirrors it (see design doc §10 "era-divergence warning"); the fresh-format convention is the opposite (right half authoritative for toe rotation — §10e).
Per-bone/chain animation lives in a separate chunk pair per track (data + time), all direct children of the system object, values in the SAME per-chain record shape as J.2's structural halves (so J.2's field offsets and J.3's animated-channel offsets are the same layout, just one is a static default and the other a keyed channel). All ORDERED within a pair (time chunk's record count must equal the data chunk's).
| Data / time IDs | Track | Data record (floats) |
|---|---|---|
| 0x012c / 0x012d | horizontal (COM xy) | header 1; record 10, position Y-up (x, y↑, z↑) at [0..2] |
| 0x012e / 0x012f | turn (COM yaw) | header 3; record 13, quat Y-up Max-convention at [4..7] |
| 0x0130 / 0x0131 | vertical (COM z) | two banks (header 1 + count×13 each — a legacy double-buffer quirk); z at [2] |
| 0x0132 / 0x0133 | pelvis | header 3; record 7, quat at [0..3] |
| 0x0134/0x0135, 0x0136/0x0137 | R arm, L arm | header 4; record 110 (right side stored first — see J.4) |
| 0x0138/0x0139, 0x013a/0x013b | R leg, L leg | header 4; record 110 (+2 floats per leg link beyond 3, i.e. 4-link "horse" legs shift every offset past the hinge by 2 — including the IK blend [12] and the end-effector positions [14..16]/[18..20], which an early reader left unshifted and thereby fed 4-link rigs garbage IK inputs; §10q) |
| 0x013c / 0x013d | spine | header 4; record 1+n: [0]=n (int), n angle floats (3/link) |
| 0x013e / 0x013f | head | header 3; record 12: head quat [0..3], zeros, [7]=neck-angle count, angles at [8..] |
| 0x0142 / 0x0143 | tail | like spine; TIME records are 26 dwords (per-link TCB parameter sets, not just one) |
| 0x0147 / 0x0148 | pony1 | like spine (2-link) |
| 0x0149 / 0x014a | pony2 | like pony1 |
Time-chunk record (10 dwords, after a 7-dword header count, ?, ?, ?, ?, trackType≈nLinks, ?): (time_ticks, index, p0..p4, tens, cont, bias). TCB params are stored in Max UI units (0..50, default 25 = neutral) at a fixed slot order (easeTo, easeFrom, tension, bias, continuity) — bias before continuity; this order is easy to get backwards since every corpus key sits at the 25 defaults and both orders look identical until a differential-dataset case (a_tcb_cont0/a_tcb_bias0) forces the two apart.
Limb animation record (110 floats/side, right half first): [0] hinge angle (elbow/knee, local Rz(a−π); horse-ankle at [1], Rz(a) as-is), [2..5] upper-segment quat (own-half convention, see J.4), [9]/[10] clavicle (Δa, Δb) delta added to the J.2 figure values (arms only), [12] IK blend 0..1 (0=pure FK, 1=fully IK-solved; see the open item below), [14..16]+1 end-effector position, body/COM-relative Y-up, [18..20]+1 end-effector position, WORLD Y-up, [28..31] foot/hand quat, [46+10k..49+10k] finger/toe chain k's base delta quat, [54+10k]/[55+10k] that chain's non-base links' absolute bend angles (radians, local Rz(angle), unwrapped mod 2π against the previous key — stored values wrap 0 → 6.22 → 0 across keys). [50+10k..53+10k] and neighbouring slots are Max-internal interpolation caches; they do not affect the evaluated pose (confirmed: identical stored local pose across files with differing cache bytes).
Two independent conventions coexist and must not be confused:
(x, y, −z, −w)) after reading them from the right half, while the left half's values are used as read. The foot and hand channels are the documented exceptions: both sides store true per-side absolute values directly (no mirror step), which is what makes free/tilted/yawed feet and pinned hands decodable at all — an early version of this decode wrongly applied the mirror there too and could only reproduce planted, canonically-oriented feet.Getting either convention backwards is easy to miss on the (mostly symmetric) legacy skeleton corpus and only shows up on asymmetric animation edits or the fresh-format differential dataset — see design-doc §10d-bis "Differential-dataset decode round" for the specific corpus files that discriminated each case.
A biped COM node linked under another node — the kitin family's Bip02 abdomen rig under Bip01 Spine, the mektoub rider's Bip01male under the saddle nodes — is carried RIGIDLY by its parent: the COM's evaluated local TM is constant (its h/v/t keytracks only modulate it when they carry genuinely varying keys, which no corpus file's linked COM does — probe-verified). The constant is derived from a stored pair (gen_biped_linkcom_probe round, design doc §10m-quater, 2026-07-10 — float-exact on every corpus linked COM, position and rotation; supersedes the earlier figure-attach approximation and the withdrawn stun-trilogy "reference-era" claim):
local = 0x0112 · C
0x0112 (12 floats: 3×3 rows + translation) = INVERSE of the parent's world TM,
captured when the link was last established/edited (link, ordinary move,
and figure-mode move of the linked COM all refresh it; moving the parent
does not). Stored in PLAIN world coordinates — NOT Y-up. Identity on
unlinked/root rigs.
C = the corrected current-position frame: 0x0104's rotation (Y-up; as a
composable TM this needs the full similarity B·Mᵀ·Bᵀ, i.e. NeL columns
(I, −K, J) of the §10-item-1 per-vector forms) with translation
0x0104.t + the 0x0260[0..2] correction vector (full-vector
generalization of the §10o HeightCorrection scalar).
Rig-internal locals are invariant under the COM re-anchoring, so only the COM's own exported track is affected. Caveat on 0x0117 (Move All): NOT identity on linked rigs (the root-rig-only measurement behind J.1's "identity on every shipped corpus file" note) and NOT part of this decode — its linked-rig semantics are unmapped.
[12] above) between keys. At key times the stored channels already ARE the IK-solved pose (verified exact for planted/free/pivot-roll/pinned-hand steady states via the differential dataset). Between keys, character studio re-solves continuously. Status: legs use the §10r pivot-constrained model (default ON). Arms use the same machinery under §10s-quat gates (default ON since 2026-07-09): palm-pivot + Object space + drop static 2-knot plants + drop large COM-yaw sessions + skip D>5 cm intervals + midFlip/locFlip reject — full-corpus A/B 15↑/3↓ max reg +0.02. Large-D weapon plants (coup_fort) and in-plant foot rotation remain open. The LargePath strike-arm residual was decomposed against live Max 9 GT (§10s-neuf): it is NOT the swivel (the pole [22..24] swivel is exact to ~0.003 rad) but arm EXTENSION — CS holds the wrist at a near-constant reach (= the stored hinge [0], ≈0.499 m on coup_fort_03) while the palm-pivot target's reach shrinks (→0.427 m), so law-of-cosines over-bends; a reach-fix experiment was net-negative on file-worst and not shipped. The per-key ikPivots / ikPivotIndex / ikJoinedPivot / ikAnkleTension fields (record tail [98], [101..103] pA, [105..107] pB, [11] space, [25] join, [109] ikAnkleTension) are consumed for the pivot model where applicable; ikAnkleTension = limb record float [109] (last float; +legShift on 4-link) — pinned 2026-07-09 via Max 9 residual-probe --diff-rig on c_ankle_t0/t05/t1 (values 0/0.5/1.0 at keytrack 0x013a floats [113]/[223] = key0/key1 field 109). Channel built in biped_anim (m_ChAnkleTension); not yet applied to in-plant foot rot (Object-space plant × tension GT re-probe pending).(easeTo, easeFrom) pair in J.3's time record). The classic Max SDK piecewise-quadratic warp is a close but not exact match to the measured curve (residual up to a few hundredths on a synthetic sweep); a 6-value differential sweep (~/biped_anim_dataset2's b_ease_* cases) exists to fit the exact shape but has not been reconciled — low corpus impact (only ~1/60 sampled corpus files key non-default ease at all), left as the documented approximation.prs:name-style plain-PRS markers parented directly to the COM (~28 corpus files): a per-template-family composition-rule split that three hypotheses (controller class, key presence, COM tilt magnitude) all failed to explain; see design doc §10l.0x014d — the single largest unknown by byte weight (~400 KB across the anim corpus, present on every biped): carries no data at all on a static skeleton (8 bytes, {0,0}), and none of the 64 figure/structure/pose/single-key probe cases touch it (§10p) — so its bulk is animation/display-era payload. Cracking it needs an animation-heavy differential probe (dense multi-frame keying) or a direct --diff-rig of two closely-related real anim files, not another skeleton-based sweep.0x0116/0x0118 (per-track siblings of 0x0110's family), 0x0201/0x0202, 0x00c8/0x00c9/0x00cb..0x00ce (siblings of the 0x00ca dynamics record), 0x0019/0x001b..0x001d, 0x0111/0x0145/0x0146 (0x0112 decoded §10m-quater — the linked-COM parent snapshot, J.1/J.4-bis), 0x0190, 0x0325/0x0322, and the 0x0709/0x070a/0x070b family (the first of which changes with uninitialized-memory-like noise across saves and is presumed junk padding). 0x0100's untouched entries and the element-level layout of the 0x01f4–0x01fc length tables also remain. All ride raw pass-through; coverage accounting lives in pipeline_max_corpus_test/biped_coverage.py (DECODED = read by the exporter; IDENTIFIED = role known, §10p).Covers the on-disk shape of the NeL scripted plugins that inherit from a standard Max object via MAXScript's extends: mechanism (a delegate reference to the extended primitive + the plugin's own ParamBlock2 for its custom params). Three plugins in this family are corpus-validated: nel_pacs_box/nel_pacs_cylinder (K.1, decoded for pipeline_max_export_pacs_prim/pipeline_max_export_cmb, design doc §10v, 2026-07-08), and nel_flare (K.4, decoded for pipeline_max_export_shape, §10z-bis, 2026-07-09). Same storage shape across all three; per-plugin param inventories differ.
nel_pacs_box/nel_pacs_cylinder/nel_flare)MAXScript plugin <SuperClass> <PluginName> ... extends: <BaseClass> ... (plugin simpleObject nel_pacs_box ... extends: Box, plugin Geometry nel_flare ... extends: Sphere, and so on) declares a plugin object whose references are the delegate + the plugin's own ParamBlock2, not a self-contained geometry object:
0x10, GeomObject, for all three of these plugins): the real Box/Cylinder/Sphere object the script extends — a genuine Max primitive with its own old-style ParamBlock (K.2) holding the dimensions.0x82, ParamBlock2): the plugin's custom params, in MAXScript declaration order whenever the .ms script's parameters pblock (...) block declares them without explicit id: tags (param index == 0-based declaration position). This is the corpus-validated case for all three of the plugins covered in this Part; a plugin whose .ms script uses id: tags would break the rule and require per-plugin remapping.Both references are found by superclass, not fixed reference index (robust against a plugin-version reference-count difference) — see findReferenceBySuperClass in pipeline_max_export_pacs_prim/main.cpp. World Position/Orientation come from the node's transform (getNodeTM), not the object; PACS getZRot-orientation semantics as in K.3.
For the PACS objects, the ParamBlock2 has 12 params: Reaction (int, 1-based radiobutton: 1=DoNothing/2=Slide/3=Reflexion/4=Stop), EnterTrigger/ExitTrigger/OverlapTrigger (bool), CollisionMask/OcclusionMask (int bitmask), Obstacle (bool), Absorbtion (float), UserData0..UserData3 (int, packed 16-bit each into NLPACS::CPrimitiveDesc::UserData's 64 bits). See K.4 for the nel_flare 57-param inventory.
0x8)The pre-ParamBlock2 format, still used by Max's own built-in primitives (Box/Cylinder/etc.) for their dimension parameters even in files that otherwise use ParamBlock2 everywhere else. Container chunk 0x0002 per parameter, children: 0x0003 (int32, the 0-based parameter index) + exactly one value chunk — 0x0102 (Point3, 12 bytes) or 0x0101 (int32) or any other id that isn't 0x0004 (float32; the "other non-0x0004" rule mirrors pipeline_max_export_ig's readPBlockParams, since the actual float value-chunk id varies and isn't worth enumerating). For Box: index 0 = length, 1 = width, 2 = height. For Cylinder: index 0 = radius, 1 = height — same index scheme pipeline_max_export_ig's buildParametricMesh already established for these same built-in primitive types. Reader: OLDPBLOCK::readOldParamBlock (pipeline_max_export_common/old_param_block.h/.cpp).
.pacs_prim and .cmb output formatsNot Max-side formats — real NeL/NLPACS types, used directly rather than hand-reproduced:
.pacs_prim: NLPACS::CPrimitiveBlock/CPrimitiveDesc (nel/pacs/primitive_block.h, serializer in nel/src/pacs/primitive_block_pacs.cpp) — XML via NLMISC::COXml. CPrimitiveBlock::serial is called directly; no hand-written XML. Orientation is CExportNel::getZRot (signed angle from global X to the Z-flattened, normalized local I-axis) for boxes, always 0 for cylinders (2D-oriented primitives only rotate about Z, and a cylinder cross-section is rotationally symmetric)..cmb: NLPACS::CCollisionMeshBuild/CCollisionFace (nel/pacs/collision_mesh_build.h, header-only) — plain binary, vertices + faces with per-face V[3]/Visibility[3]/Surface/Material. Visibility[] is a reference-side index remap of Max's own Face::flags edge-visibility bits (not [0]=EDGE_A,[1]=EDGE_B,[2]=EDGE_C as the naming would suggest — the reference exporter itself stores [0]←EDGE_B, [1]←EDGE_C, [2]←EDGE_A).nel_flare scripted plugin — ParamBlock2 layoutplugin Geometry nel_flare ... extends: Sphere (plugin_max/scripts/startup/nel_flare.ms), ClassId (0x4e913532, 0x3c2f2307), superclass 0x10 (GeomObject). Uses the shape of §K.1 verbatim; its ParamBlock2 (reference 1, superclass 0x82) declares 57 params without explicit id: tags, so IDs equal MAXScript declaration order (corpus-validated on 24 flare-carrying .max files via a python PB2 dump against zo_cn_mairie.max — 7 flare PB2 objects, every id/value matched the declaration order):
| IDs | Params | Kind |
|---|---|---|
| 0–9 | texFileName0..9 |
string (per-flare texture file) |
| 10–19 | flareUsed0..9 |
bool (per-flare enable) |
| 20–29 | size0..9 |
float (per-flare size) |
| 30–39 | pos0..9 |
float (per-flare relative position along the axis) |
| 40 | ColorParam |
RGB (float triple, 0..1) |
| 41 | PersistenceParam |
float |
| 42 | Spacing |
float |
| 43 | AttenuationRange |
float |
| 44 | Attenuable |
bool |
| 45 | FirstFlareKeepSize |
bool |
| 46 | HasDazzle |
bool |
| 47 | DazzleColor |
RGB |
| 48 | DazzleAttenuationRange |
float |
| 49 | MaxViewDist |
float |
| 50 | MaxViewDistRatio |
float |
| 51 | occlusionTestMesh |
string (occlusion test shape file name) |
| 52 | occlusionTestMeshInheritScaleRot |
bool |
| 53 | sizeDisappear |
float |
| 54 | angleDisappear |
float |
| 55 | scaleWhenDisappear |
bool |
| 56 | lookAtMode |
bool |
Reference exporter reads these by NAME through getValueByNameUsingParamBlock2 (plugin_max/nel_mesh_lib/export_flare.cpp); the headless path keys on the serialization-stable IDs above. Color conversion is (uint)(255.f * col.x) — plain float→uint8 truncation, NOT the material_build path's +0.5 rounded convertColor; landing on different byte-boundary endpoints matters for byte-identity. Reader: FLAREBUILD::buildFlareShape (pipeline_max_export_shape/flare_build.h/.cpp).
MeshDelta, per-node mod-app payload)Identity: Class ID (0x50, 0), superclass 0x810 (OSModifier); DLL mods.dlm. Applied on the OSM Derived wrapper stack; per-node local data lives on the wrapper's orphaned 0x2500 slots — one per modifier position, in reference order, INCLUDING for modifiers that themselves have no per-node data (a 0x2500 slot is emitted for every modifier position; empty/pass-through modifiers get a shell).
Decoded across four sessions: first for pipeline_max_export_ig's clusterize link-test path (design doc §10g), expanded 2026-07-08 (§10w) with the corrected created-verts chunk id and the two created-faces chunk variants, then the same day (§10x, pipeline_max_export_cmb) with the per-face attribute-change record (0x0220 = matID + edge-visibility rewrites), and again 2026-07-09 (§10z-ter, cmb coverage session) correcting the 0x0210 classification: what §10x recorded as "created map-1 texture-vertex faces" is actually the FACE-VERTEX REMAP table, modern equivalent of legacy TOPO_FACEMAP_CHUNK 0x2780 — per-face per-corner index rewrites on the input face set, (uint32 faceIdx, uint32 applyMask, uint32 v[3]) at 20-byte stride. Corpus survey of 445 files / 113 chunks / 2881 entries: every observed mask is in {0..7} (mask=3 is the "flip edge diagonal" operation, mask=7 is a full face-vertex replacement), and every faceIdx is < 65536 — the earlier "created-map-1-face" reading fit zero entries. Contents below combine the authoritative Max SDK spec for the modifier stream + local-data stream framing (§ L.4, § L.5, § L.6 — sources: user-supplied MeshDelta documentation) with the corpus-validated chunk map for the modern MDELTA_CHUNK sub-tree (§ L.2), which the SDK doc leaves "outside scope".
0x2500, on the OSM Derived wrapper)Per-node, per-modifier-slot payload, on the wrapper ({0x29263A68, 0x405F22F5}) as an orphaned chunk. Common shape across modifiers:
| ID | Payload | Kind |
|---|---|---|
0x2510 |
4×3 float row-major matrix (48 B) + uint32 flags (4 B) = 52 B |
mod-context TM (the modifier's placement transform on this node) |
0x2511 |
ctx bbox (external) | rarely present |
0x2512 |
container → 0x4000 |
the modifier's payload (Edit Mesh, Mirror, etc.) |
Chunk 0x2510's 52-byte layout is a Matrix3 in Max row-vector convention (rows 0–2 = basis vectors as Point3, row 3 = translation, 4-byte trailing state flags), with per-row Point3 stride 12; the identity-with-translation form (rows 0–2 = (1,0,0)/(0,1,0)/(0,0,1), row 3 = tx/ty/tz) is what a Mirror gizmo with translation-only offset stores. Ref: pipeline_max_export_ig/main.cpp SModOp reader.
0x2500 → 0x2512 → 0x4000)0x4000 is a container whose children encode the MeshDelta operations to apply to the incoming stack mesh. The children below are what pipeline_max_export_ig's readEditMeshModApp decodes; siblings not listed are pass-through undo/UI state and can safely be ignored by an evaluator (they are still preserved verbatim by the roundtrip).
| ID | Layout | Meaning |
|---|---|---|
0x0100 |
int32 |
input vertex count (base mesh at this stack level) |
0x0110 |
int32 |
input face count |
0x0130 |
uint32 count + (uint32 srcTag, Point3 pos)[count] — 16-byte stride |
created vertices (the modifier's added geometry). (Corrected 2026-07-10, PMD §10z-quinze — the earlier "srcTag ignored on evaluation" reading was wrong.) srcTag == 0xFFFFFFFF → a fresh vertex, pos is the absolute object-space position (the fy_mairie _puit_carre2/3 wells class, §10w). srcTag != -1 → a CLONE of input vertex srcTag, pos is the OFFSET from the source vertex's PRE-move position — the modern merge of legacy TOPO_CVERTS_CHUNK clone-sources (§L.5) with the clone's own move; chamfer/extrude record their geometry this way, and the legacy effect order (§L.6: clones step 3, moves step 13) is why the offset resolves against the un-moved source. Corpus-pinned: zo_bt_hall_reunion_vitrine's 25 clones-of-moved-sources land exactly one move-delta off when resolved post-move; FY_hall_reunion.cmb/Zo_bt_hall_Reunion_vitrine.cmb are byte-identical under the clone+pre-move read; the primes_racines sky-dome clones (sources on a radius-128 sphere) read as absolute produced the |
0x0140 |
uint32 count + (uint32 index, Point3 delta)[count] — 16-byte stride |
moved vertices (positional deltas in object space, applied additively to the base vertex at index). |
0x0170 |
container → 0x2700 uint32 bitCount + dword-padded LSB-first bits |
deleted-vertex bitfield |
0x0208 |
uint32 count + (uint32 srcTag, uint32 v[3], uint32 smGrp, uint32 flagsMatId)[count] — 24-byte stride |
created BASE-topology faces (with srcTag; pairs with 0x0130 created verts). Corpus-validated (§10x): fy_hall_reunion reference .cmb has exactly input+kept − delFaces + 0x0208.count = 248 faces on the one node with created geometry. Modern equivalent of legacy TOPO_NFACES_CHUNK (§L.5). flagsMatId packs edge-visibility (low 3 bits: EDGE_A=1, EDGE_B=2, EDGE_C=4) + material id (high 16 bits) in one dword; srcTag is the authoring-history hint, ignored on evaluation. |
0x0210 |
uint32 count + (uint32 faceIdx, uint32 applyMask, uint32 v[3])[count] — 20-byte stride |
face-vertex remap — per-face per-corner index rewrites on the INPUT face set. applyMask bits 0..2 select which of face faceIdx's three corners get replaced with the corresponding v[i]; corners not covered by mask carry writer-uninit bytes and must be ignored. Modern equivalent of legacy TOPO_FACEMAP_CHUNK 0x2780 (§L.5), whose per-corner UNDEFINED sentinel is split into a parallel applyMask word here — same shape the modern 0x0220 face-attribs record uses (parallel apply + values words). Applied BEFORE deletes so the deleter sees post-remap indices, and BEFORE created-vert append so a remap pointing to a not-yet-appended created-vert index stays valid (the append lands them at exactly the referenced indices). Corpus-validated across 445 files / 113 chunks / 2881 entries: every observed mask is in {0..7}, every faceIdx is < 65536. The most common mask=3 (bits 0+1, corners 0 and 1) is the "flip edge diagonal" operation; mask=7 is a full face-vertex replacement (e.g. fy_hall_reunion's face 18 remap to (76, 81, 74) matching the reference .cmb exactly, §10z-ter). Modern-format equivalents of TOPO_NTVFACES_CHUNK (map-1 tex-vertex created faces) and TOPO_TVFACEMAP_CHUNK (tex-vertex face remap) are not yet located in the corpus. |
0x0220 |
uint32 count + (uint32 faceIdx, uint32 applyMask, uint32 values)[count] — 12-byte stride |
per-face attribute changes — updates to matID / edge-visibility / face-hidden on the INPUT face set. values bit layout follows the legacy TOPO_ATTRIBS_CHUNK (§L.5, § L.5.3): bits 0..2 = edge-visibility mask, bit 3 = face-hidden, bits 5..20 = matID (extract via (values >> 5) & 0xFFFF). applyMask bit layout is the parallel-word split of the legacy chunk's own bits 28..31: bits 0..2 = per-edge apply, bit 3 = apply hidden, bit 4 = apply matID. Corpus-validated: fy_hall_reunion has 82 entries (= input face count), applying them promotes the base mesh's uniform-matID-0 faces to matID {57, 58, 59, 60} exactly matching the reference .cmb. Modern equivalent of legacy TOPO_ATTRIBS_CHUNK (§L.5). |
0x0270 |
container → 0x2700 bitfield |
deleted-face bitfield |
0x0300 |
int32 |
subobject level (0..5, same domain as PART F 0x4038); state, not geometry — same enum as SEL_LEVEL_CHUNK (§L.4.1) |
0x0400 / 0x0410 / 0x0420 |
container → 0x2700 bitfield each |
selection payloads (vertex / face / edge — one per level); ignored on evaluation |
Application order (matches the Max SDK's MeshDelta::Apply and corpus behavior): face attribs → moves → face-vertex remap → face deletes → append created verts → append created faces → vert deletes (with face reindex over the union input⊕created space). pipeline_max_export_ig applies moves + face-vertex remap + deletes + created verts only (facesMode=0 in the shared EDITMESH::applyEdits) — its cluster-containment link test operates on vertices only, and appending faces would change its cluster volumes. pipeline_max_export_cmb applies face attribs (matID/edge-vis rewrites) + moves + face-vertex remap + deletes + created verts + created base faces (0x0208, facesMode=1) — matches the reference .cmb's material distribution + face-vertex references exactly (fy_hall_reunion 248/248 face-index match, §10z-ter). Both consumers share pipeline_max_export_common/edit_mesh_mod.h.
Portals/clusters in the corpus are typically Box base + Edit Mesh stack even though PART F 0x40xx Editable Mesh base data is also present in some files; the artist workflow is (a) create a Box (parametric primitive, topology per pipeline_max_export_ig::buildParametricMesh), then (b) stack an Edit Mesh modifier that deletes faces/verts and moves the remainder to make a portal quad or a cluster convex hull. The 0x2500 → 0x4000 MeshDelta records the operations relative to the parametric Box, not the final result — evaluating them at export time is what turns a Box+EditMesh into the shipping portal poly / cluster volume. Same pattern applies to Mirror modifier ((0xef92aa7c, 0x511bbe75), also in mods.dlm): PART K.2-style old ParamBlock parameters (axis 0..5, offset, copy) + a PRS controller for the gizmo TM + the 0x2510 mod-context TM inside the 0x2500 slot; ref decode in pipeline_max_export_ig::applyMirror.
Chunks written on the modifier scene object itself (after the base modifier serialization — parameter block, name — which is outside this section's scope). Written in the listed order.
SEL_LEVEL_CHUNK = 0x2800)Payload: int16 — current sub-object selection level. Enum values:
| Value | Level |
|---|---|
| 0 | SL_OBJECT |
| 1 | SL_VERTEX |
| 2 | SL_FACE |
| 3 | SL_POLY |
| 4 | SL_EDGE |
| 5 | SL_ELEMENT |
Legacy remap (auth-doc § 2.4): pre-legacy-conversion level 2 → new SL_POLY (3), pre-legacy-conversion level 3 → new SL_EDGE (4). Other legacy levels map identically. The remap is applied at the end of legacy-conversion (§ L.6); modern writers write the current-enum values verbatim.
Chunk id 0x2800 is REUSED inside TOPO_DELTA_CHUNK (§L.5) for TOPO_DFACES_CHUNK — the scopes never overlap (one is at the modifier level, the other inside a legacy container), so dispatch by chunk id + parent context.
Three parallel container chunks, one per sub-object level. Each is written only if at least one named set exists at that level.
| Container Chunk | ID | Purpose |
|---|---|---|
NAMEDVSEL_NAMES_CHUNK |
0x2805 |
Vertex-level named sets |
NAMEDFSEL_NAMES_CHUNK |
0x2806 |
Face-level named sets |
NAMEDESEL_NAMES_CHUNK |
0x2807 |
Edge-level named sets |
Inside each container, for each named set (in insertion order), two child chunks are written as an ordered pair:
| Child Chunk | ID | Payload |
|---|---|---|
NAMEDSEL_STRING_CHUNK |
0x2809 |
Wide (UTF-16) string, host's standard wide-string primitive (length prefix + UTF-16 code units) |
NAMEDSEL_ID_CHUNK |
0x2810 |
uint32 — set's unique identifier |
The (name, id) association is preserved by ordering: the n-th NAMEDSEL_STRING_CHUNK inside a container pairs with the n-th NAMEDSEL_ID_CHUNK inside that same container.
Chunk id 0x2810 is REUSED inside TOPO_DELTA_CHUNK (§L.5) for TOPO_INPUTFACES_CHUNK — same scope-disjoint disambiguation as 0x2800.
| Chunk | ID | Payload size | Payload | Meaning |
|---|---|---|---|---|
AR_CHUNK |
0x2820 |
4 B | int |
Affect-region enable flag (0 = off, non-zero = on) |
FALLOFF_CHUNK |
0x2822 |
4 B | float (see interop) |
Falloff distance |
PINCH_CHUNK |
0x2824 |
4 B | float (see interop) |
Pinch |
BUBBLE_CHUNK |
0x2826 |
4 B | float (see interop) |
Bubble |
EDIST_CHUNK |
0x2828 |
4 B | int |
Use edge-distance flag |
EDGE_ITS_CHUNK |
0x282a |
4 B | int |
Edge iteration count |
IG_BACK_CHUNK |
0x282c |
4 B | int |
Ignore-back-facing flag |
Interop note. Observed writers declare the length of the three float chunks (0x2822, 0x2824, 0x2826) as sizeof(int) rather than sizeof(float). On any x86 target these are both 4 bytes, so the length field reads 4 either way and the payload is a normal IEEE-754 32-bit float. Byte-compatible writers must emit 4 as the length and a 32-bit float payload. Readers treat the payload as a float regardless.
Chunk id 0x2820 is REUSED inside TOPO_DELTA_CHUNK (§L.5) for TOPO_INPUTVERTS_CHUNK.
EMOD_FLAGS_CHUNK = 0x2830)Payload: uint32 — the persistent portion of the modifier's flag word. The auth-doc splits the modifier's live flag word into two disjoint bit sets: a persistent mask (survives across sessions) and a transient mask (session-only). The written value is flags & persistent_mask.
A pure exporter only writes files: emit the modifier's flag word masked to the persistent set. A reader maintaining a live flag word should preserve any bits already set within the transient mask and OR in the loaded value.
Chunk id 0x2830 is REUSED inside TOPO_DELTA_CHUNK (§L.5) for TOPO_ATTRIBS_CHUNK — same scope-disjoint disambiguation.
Some UI-limited builds of the target application force the AR_CHUNK value to FALSE on load regardless of what's stored. A byte-compatible exporter still writes the true value; this only affects readers.
One local-data stream is written per instance context of the modifier. § L.1 / § L.2 already covered the modern-format MDELTA_CHUNK sub-tree that carries the geometry deltas; this section adds the modern-format sibling chunks (§ L.5.1) and the legacy read-only format (§ L.5.2) preserved for backward compatibility.
Written in the order below. MDELTA_CHUNK is where the mesh deltas live (§ L.1 / § L.2 — the chunks that consumers actually evaluate).
| Chunk | ID | Payload |
|---|---|---|
FLAGS_CHUNK |
0x2740 |
uint32 — instance flag word |
VSELSET_CHUNK |
0x2845 |
Vertex-level named selection-set list (nested chunk stream) |
ESELSET_CHUNK |
0x2847 |
Edge-level named selection-set list (nested chunk stream) |
FSELSET_CHUNK |
0x2846 |
Face-level named selection-set list (nested chunk stream) |
MDELTA_CHUNK |
0x4000 |
Mesh delta (§ L.1 / § L.2) |
Each of the three selection-set chunks is written only if the corresponding list is non-empty.
Ordering notes.
0x2845), F (0x2846), E (0x2847) and even though the modifier-level named-set container IDs (§ L.4.2) are V-F-E. The application's runtime enumeration for these levels internally is V-E-F, and that is what determines the write order. A byte-exact writer must emit V (0x2845), then E (0x2847), then F (0x2846).Older exports have no MDELTA_CHUNK. Instead they carry the chunks below, expressing selection state, per-vertex move deltas, and a topology-delta container. Encountering any of these in a local-data stream must trigger a legacy-conversion pass (§ L.6) after the whole scene has been read, translating the legacy data into an equivalent modern mesh delta.
Modern writers must never emit any of these chunks.
Legacy selection BitArrays (each uses the host's standard BitArray framing — bitCount + packed word storage):
| Chunk | ID | Payload |
|---|---|---|
VSEL_CHUNK |
0x2750 |
vertex selection |
FSEL_CHUNK |
0x2755 |
face selection |
ESEL_CHUNK |
0x2756 |
edge selection |
Legacy vertex-delta chunks:
| Chunk | ID | Payload |
|---|---|---|
VERT_HIDE_CHUNK |
0x2761 |
BitArray — vertex hide flags |
VERT_DELTA_CHUNK |
0x2760 |
Array of 3-float vectors (per-vertex move offsets); element count = chunk_length / 12 |
VERT_DELTAZERO_CHUNK |
0x3000 |
int32 n — compact "n zero move deltas" |
Only one of VERT_DELTA_CHUNK or VERT_DELTAZERO_CHUNK appears in a given legacy stream.
Legacy topology-delta container (TOPO_DELTA_CHUNK = 0x2765). Child chunks:
| Child Chunk | ID | Payload |
|---|---|---|
TOPO_CVERTS_CHUNK |
0x2770 |
Array of uint32 — cloned vertex indices |
TOPO_NVERTS_CHUNK |
0x2771 |
Array of 3-float — newly created vertex positions |
TOPO_NTVERTS_CHUNK |
0x2772 |
Array of 3-float — newly created texture-vertex positions |
TOPO_NFACES_CHUNK |
0x2775 |
Array of triangle-face records — newly created faces |
TOPO_NTVFACES_CHUNK |
0x2776 |
Array of texture-face records — newly created tex-vertex faces |
TOPO_FACEMAP_CHUNK |
0x2780 |
Array of face-vertex remap records (§ L.5.3) |
TOPO_FACEMAPUNDEF_CHUNK |
0x3010 |
int32 n — compact: n remaps, each all-UNDEFINED |
TOPO_TVFACEMAP_CHUNK |
0x2781 |
Array of texture-face remap records |
TOPO_TVFACEMAPUNDEF_CHUNK |
0x3020 |
int32 n — compact all-UNDEFINED |
TOPO_DVERTS_CHUNK |
0x2790 |
Array of uint32 — vertex-delete indices (post-deletion indexing; § L.5.4) |
TOPO_DFACES_CHUNK |
0x2800 |
Array of uint32 — face-delete indices (post-deletion indexing; § L.5.4) |
TOPO_ATTRIBS_CHUNK |
0x2830 |
Array of uint32 — packed per-face attribute changes (§ L.5.3) |
TOPO_ATTRIBSZERO_CHUNK |
0x3030 |
int32 n — compact: n zero-valued attribute entries |
TOPO_SMGROUPS_CHUNK |
0x2840 |
Array of uint32 — per-face smoothing-group bitmasks |
TOPO_SMGROUPSSAME_CHUNK |
0x3040 |
int32 n + uint32 sg — compact: n entries, each = sg |
TOPO_INPUTVERTS_CHUNK |
0x2820 |
uint32 — vertex count at modifier input |
TOPO_INPUTFACES_CHUNK |
0x2810 |
uint32 — face count at modifier input |
Chunk ID reuse (§L.4 note): 0x2800/0x2810/0x2820/0x2830 are reused between the modifier stream and the legacy topology-delta container — dispatch by parent scope.
Face remap records — each is three consecutive uint32, one per triangle corner. Each slot is either a new vertex index or the sentinel UNDEFINED (0xFFFFFFFF) meaning "leave this corner unchanged."
Modern-format counterpart (0x0210 in the MDELTA_CHUNK sub-tree, § L.2 table): the per-corner UNDEFINED sentinel is split off into a parallel applyMask word — 20-byte record (uint32 faceIdx, uint32 applyMask, uint32 v[3]) where applyMask bits 0..2 select which corners get replaced (bit i set ⇒ replace corner i with v[i]; bit clear ⇒ v[i] carries uninit bytes and is ignored). Same "split off the apply bits into a parallel word" transformation the modern 0x0220 uses vs legacy TOPO_ATTRIBS_CHUNK. Corpus (§10z-ter) validates: 445 files / 113 chunks / 2881 entries, every observed mask ∈ {0..7}, every faceIdx < 65536 — fy_hall_reunion face 18 remap (mask=0x7, v=(76, 81, 74)) reproduces the reference .cmb's face-vertex references exactly.
Legacy attribute bit packing — each uint32 in TOPO_ATTRIBS_CHUNK encodes optional attribute changes for one face:
| Bits | Meaning |
|---|---|
| 0–2 | Edge-visibility mask (three edges, one bit each) |
| 3 | Face-hidden flag |
| 5–20 | Material ID (16 bits) |
| 28 | Apply-smoothing-group: pull value from parallel TOPO_SMGROUPS_CHUNK |
| 29 | Apply-material-ID: use the material ID field |
| 30 | Apply-face-hidden: use the face-hidden bit |
| 31 | Apply-edge-visibility: use the edge-visibility mask |
Bits 4, 21–27 are unused. Only fields whose "apply" bit is set contribute to the mesh delta.
Modern-format counterpart (0x0220 in the MDELTA_CHUNK sub-tree, § L.2 table): the four apply bits from bit-31/30/29/28 are split into a parallel applyMask word (a 12-byte record grew out of a 4-byte word), leaving the values word laid out identically to bits 0..20 above; the apply bits sit at parallel positions 0..2 (edges), 3 (hidden), 4 (matID). The corpus (§10x) validates this by producing the reference .cmb's exact material distribution from the extracted matIDs.
Legacy vertex- and face-delete lists use post-deletion indexing — each successive index refers to the current array after all preceding deletions have been applied. To delete elements originally numbered 2 and 3 from a five-element array, the list is [2, 2] — the first 2 deletes element 2, the second 2 refers to what was originally element 3 (now at index 2). Legacy conversion (§ L.6) unwinds this into absolute (pre-deletion) indices before feeding to a modern delete operation.
When a local-data stream contained any legacy chunk, after the containing scene has finished loading, the reader must run the conversion below. The result is a mesh delta whose net effect matches the legacy semantics. A companion "switch-subobject-versions" flag is raised to indicate that legacy-era sub-object level numbers need remapping.
Conversion procedure, in order (matching the effect order used by legacy code so resulting deltas replay identically):
TOPO_INPUTVERTS_CHUNK and TOPO_INPUTFACES_CHUNK.TOPO_CVERTS_CHUNK.TOPO_NVERTS_CHUNK.TOPO_NTVERTS_CHUNK.TOPO_NFACES_CHUNK.TOPO_NTVFACES_CHUNK.TOPO_FACEMAP_CHUNK entries into face-remap operations. Because the legacy remap references post-deletion indices, use vertex- and face-lookup tables built during the delete-unwind steps to translate them into pre-deletion (canonical) indices before recording.TOPO_TVFACEMAP_CHUNK against map slot 1's face-remap.TOPO_ATTRIBS_CHUNK per the bit layout in § L.5.3, emitting face-change and face-smooth records as described.VERT_DELTA_CHUNK (or none, if VERT_DELTAZERO_CHUNK was present).VSEL_CHUNK, VERT_HIDE_CHUNK, ESEL_CHUNK, and FSEL_CHUNK into the instance's live selection state, resizing to the output topology sizes. The edge-selection BitArray is sized as three times the face count (three edges per face).Sub-object level remap. When the switch-subobject-versions flag is set at the end of conversion, remap the modifier's stored selection level from legacy to current: legacy 2 → SL_POLY (3), legacy 3 → SL_EDGE (4); other levels map identically. Clear the flag once done. See § L.4.1 for the current enum values.
Post-load repair (modern format). When the modern MDELTA_CHUNK was read (no legacy conversion needed), two integrity checks are recommended on the loaded mesh delta: an operation-order check that verifies (and if necessary repairs) that internal delta operations are in the expected canonical order, and a map-face consistency check that verifies mapping-face records remain consistent with the base topology. Both checks are silent in production; debug builds may log when repairs occur. For a writer, both checks are the responsibility of the producing pipeline — write only well-formed mesh deltas and there is nothing to repair on the reading side.
Status: PRODUCTION PATH LANDED (2026-07-09, §10z-sept); boneRef decode COMPLETE (2026-07-10, §10z-treize + §10z-clod-bis). The mod-app container structure is corpus-mapped (§ M.1); the per-vertex record layout and the full link-index decode — negative rigid form, positive cross-link form, the segment-START parent rule, and the three link-tree boundary rules (root-attach boneRef = 0, NULL chain-attach ends, COM-skip on cross-links) — are decoded and shipping in pipeline_max_export_common/physique_skin. No clod-corpus vertex remains unresolved; the skeleton-root fallback now fires only for genuinely EMPTY records (numBones = 0 — the shape corpus's floating-vert class, where the reference's own ConvertToRigid root promotion produces the same skeleton-root weight, e.g. Bip01 as bone[0] on fy_hof_armor01_pantabottes).
Identity: Class ID (0x100, 0), SuperClassId 0x810 (OSModifier); DLL phyexp.dlm. Note the class ID (0x100, 0) is shared with three unrelated Max classes: Placement (SuperClassId 0xc20), Output (0xc40), and Shadow Map (0x10d0) — the SuperClassId 0x810 is the discriminator for Physique. Corpus-verified against the fyros/tryker/matis armor .max files.
Related modifier: Skin (Class ID (0x0095c6a3, 0x00015666), SuperClassId 0x810) — same SDK-modifier role, distinct binary format (not covered in Part M; Skin uses ISkin / ISkinContextData in the SDK, Physique uses IPhysiqueExport / IPhyContextExport — the two have different storage layouts, see reference plugin_max/nel_mesh_lib/export_skinning.cpp). Skin decode is deferred to a future round.
0x2500, on the OSM Derived wrapper)Same standard modifier framing as Edit Mesh (§ L.1). Corpus-verified structure across every skinned node in every armor .max sampled:
| ID | Payload | Meaning |
|---|---|---|
0x2510 |
4×3 float row-major matrix + uint32 state flags = 52 B |
mod-context TM (identical to Edit Mesh's 0x2510) |
0x2511 |
24 B | mod-context bbox (empty on Physique in practice — bytes read as NaN pattern caf249…) |
0x2512 |
container → 0x2504 |
Physique's payload container (Edit Mesh has 0x2512 → 0x4000 for MDELTA_CHUNK; Physique has 0x2512 → 0x2504) |
0x2513 |
4 B (u32) | trailing state word, 04000000 = 4 uniformly across the corpus (Physique version?) |
Inside 0x2504 — the Physique instance data (variable child count = 4 + numVertices):
| ID | Order | Kind | Meaning |
|---|---|---|---|
0x2501 |
fixed 1st | leaf 12 B | Header. Observed 01 00 00 00 05 01 00 00 00 00 00 00 uniformly — likely (u32 version=1, u32 flags=0x105, u32 reserved=0). Unproven, corpus-uniform on Physique v4. |
0x2502 |
fixed 2nd | container | Deformable-space transform block. Children: 0x03e8 (48 B, 4×3 Matrix3 = identity+translation for the reference bind pose) + 0x03f2 (4 B int = 4, likely a flags/type marker). |
0x2509 |
fixed 3rd | leaf 17 B | Global parameters. Observed 00 80 9c 43 00 00 00 00 ff ff ff ff ff ff ff ff 00 uniformly = (float 313.0f, float 0.0f, u32 0xffffffff, u32 0xffffffff, byte 0) — 313.0 is a suspicious "sentinel/version" magic (not a distance, since deformation reach would typically be smaller). Corpus-uniform on Physique v4. |
0x2802 |
fixed 4th | container | REFERENCE-POSE snapshot. Byte-identical to 0x2502 in every corpus instance — the "initial matrix" copy Physique keeps for the bind reference. |
0x2506 |
N times | container → 0x0989 |
Per-vertex rigidity record, in mesh-vertex-index order. The number of 0x2506 children under 0x2504 equals the source mesh's vertex count (verified: FY_HOF_armor01_pantabottes has 527 total 0x2504 children = 4 headers + 523 per-vertex records, matching the mesh vertex count as read via the exporter's evalNodeMesh). |
0x2506 → 0x0989)Fixed layout, size = 4 + 20·numBones bytes:
uint32 numBones // 2 or 3 on the corpus samples
for i in 0..numBones-1:
uint32 boneRef // bit pattern (see M.3), NOT a plain scene-storage index
Point3 offset // 12 bytes, floats — bone-local-frame offset from bone origin to vertex
float weight // per-bone weight or blend factor
Corpus example (fy_hof_armor01_pantabottes vertex 0, 2-bone record, 44 bytes total):
02 00 00 00 // numBones = 2
ff ff ff ff 54 f2 9e 3f 32 23 35 3d 59 eb b2 42 00 00 80 3f
// bone 0: boneRef=0xFFFFFFFF, offset=(1.242, 0.044, 89.464), weight=1.0
fe ff ff ff f3 99 44 3e 4c 35 35 3d 74 eb b2 42 00 00 80 3f
// bone 1: boneRef=0xFEFFFFFF, offset=(0.192, 0.044, 89.459), weight=1.0
3-bone example (same file, vertex 1, 64 bytes total):
03 00 00 00
ff ff ff ff cd 48 16 3d 96 64 b4 3d b9 22 87 43 00 00 80 3f
ad ff ff ff b5 bd 19 bd 69 75 00 3e 4d f9 9d 43 cd cc 4c 3e
b3 ff ff ff c3 77 1a bd 54 11 01 3e dc c9 5f 43 cd cc 4c 3e
The full boneRef decode (2026-07-10, empirically solved; supersedes the two earlier partial readings in this section): the stored value encodes a LINK index k — negative form k = ~stored (the rigid/primary encoding, bytes [XX ff ff ff]), positive form k = stored − 1 (the deformable cross-link encoding; the "different record structure" hypothesis below was wrong — every 0x0989 record is exactly 4 + 20·numBones bytes corpus-wide). Link k is the segment ENDING at the bone in reference slot k+1, and the vertex deforms by the segment's START bone = the INode PARENT of ref[k+1] — not ref[k] itself. The two coincide for mid-chain slots (parent(ref[k+1]) == ref[k]), which is why a plain ref[k] read looks anatomically correct almost everywhere; at chain boundaries only the parent rule matches the reference exporter:
| ref[k+1] | deforming bone (= its INode parent) |
|---|---|
Bip01 R Hand |
Bip01 R Forearm |
Bip01 R Finger0 (k+1 right after a NULL chain-attach slot) |
Bip01 R Hand |
Bip01 R Toe0 (the name-shadowed nub) |
Bip01 R Toe0 (the real toe) |
Bip01 Spine |
Bip01 Pelvis |
Solved by weight-multiset matching over every position-matched vertex of the five fy_hof_armor01 nodes against their reference CMeshMRMSkinned shapes (1869/1871 verts matched; 100 % bone agreement, residual deltas bounded by the reference's uint8 weight quantization).
Link-tree boundary rules (2026-07-10, clod-audit round — corpus-pinned per-vertex against the clod references; PMD §10z-clod-bis):
boneRef = 0 = the ROOT-ATTACH link (k = −1): deforms by the link-tree root bone — ref[0] when non-NULL ('Bip01 Pelvis' on every biped-attached corpus rig), else the INode PARENT of the first non-NULL ref (C04's ref table starts [NULL, 'Bip01 Spine', …] with Pelvis absent; the reference deforms those verts by Pelvis = parent(Spine)). Never the COM — the earlier "weighting Bip01 is unrepresentable / root promotion" reading conflated this stored form with ConvertToRigid's promotion of EMPTY records (which does land the skeleton root and stays the fallback's one legitimate case).ref[k+1] on a k ≥ 0 link is a deleted/free chain-attach end and ALSO deforms by the link-tree root. NULL slots ARE addressed by valid links (diranak/estrasson's [-19] → the NULL ref[19] — the armor01 family just happened to carry none, which is where the earlier "never addressed" note came from), and the influence is KEPT inside blends at its stored relative weight (diranak reference: Pelvis 0.0909 / Spine 0.9091 = stored 0.1 / 1.0).Bip02 itself for their rigid links; an unconditional skip regresses them (measured).Skinned meshes export their vertices in WORLD space (reference export_mesh.cpp:671: ToExportSpace = GetObjectTM when skinned — the runtime deforms by the skin weights, not the node transform; DefaultPos/Rot/Scale stay the node's local transform). This was the missing half of the §10z-sept landing: our skinned VBs were in node-local space, which is why no skinned vertex ever position-matched its reference.
The weight field does not sum to 1 across records (2-bone case has both weights = 1.0; 3-bone case has (1.0, 0.5, 0.2) summing to 1.7) — consistent with the reference's buildSkinning keeping the top-4 IPhyBlendedRigidVertex::GetWeight(i) values and dividing by their sum (the reference does NOT consume the per-bone offset vectors for .shape SkinWeights — those feed the bind pose via GetInitNodeTM, which the skeleton already supplies — so a headless decode needs only boneRef→bone, weight, and the top-4+normalize). Whether the stored weight is already the value GetWeight returns after the SDK's ConvertToRigid(TRUE)/AllowBlending(TRUE) (a no-op on already-rigid/blended data) vs a rigidity factor needing replay is the open validation (M.3 item 2).
getReference(i) slots, NOT the mod-app). Confirmed on fy_hof_armor01_pantabottes: the modifier has 87 references, each a bone INode ('Bip01 Pelvis', 'Bip01 Spine', … 'Bip01 R Toe0'), with 16 NULL slots interspersed (Physique reserves a slot per link-tree position; the NULLs are counted in the index — Bip01 Head sits at index 6 because index 5 is NULL). boneRef = ~index (M.2) resolves a primary/rigid link directly to ref[index]. No differential dataset was needed for this half — the bone table is plain scene-reference wiring, located via PMB_SKIN_DUMP's modifier-ref dump (M.4). The bone-name array the .shape needs (M.3 item 4) is therefore also solved: walk the modifier refs, take each non-NULL INode's name.0x4d=77) do NOT fit ~index — they are Physique deformable cross-links (the stored per-vertex data is the DEFORMABLE link tree, and ConvertToRigid(TRUE) — which the SDK applies at export — collapses it to rigid/blended). Cracking these needs a Max 9 differential dataset (two variants of a minimal skinned mesh with one vertex's link tree changed), same channel as ~/biped_dataset..shape mapping + the Bip01/root-link discrepancy. The reference shape stores bone NAMES + top-4 normalized weights. Two blockers remain: (i) confirming the stored weight is the final GetWeight value (likely yes for already-rigid records — needs one differential probe); (ii) the root-link reassignment — the reference fy_hof_armor01_pantabottes.shape lists Bip01 (the COM) as bone[0] with many rigid vertices, but Bip01 is NOT in the modifier's 87 references, so those vertices arise from ConvertToRigid promoting deformable vertices to the root link. A headless replay of ConvertToRigid's link-hierarchy logic (the same SDK object code that resolves IPhyBlendedRigidVertex::GetNode) is the missing piece; if it is not reproducible from stored bytes alone, byte-identity is closed for Physique and coverage-only routing (structural mesh + best-effort/placeholder weights) is the pragmatic fallback.PMB_SKIN_DUMP=1 env-gated tree dump can be reused (already in pipeline_max_export_shape/main.cpp, now also dumping the modifier's references + chunks — M.4) — needs a Skin-carrying corpus file to feed it.INode ref), matched against the walked skeleton's INode* → boneId map (the reference plugin's buildSkinning uses exactly this — skeletonShape.find(boneNode) — so the .shape's BonesNames is the SKELETON's bone list, in skeleton-walk order, reduced to the used subset by the mesh build's bone-use pass; the modifier refs only identify WHICH bones each vertex uses).pipeline_max_export_shape compiled with the PMB_SKIN_DUMP=1 environment variable emits, per Physique/Skin node, (a) a recursive chunk-tree dump of the mod-app 0x2500 slot — chunk id, container-vs-leaf flag, raw byte size, and hex preview (first 128 bytes) of every leaf (the per-vertex 0x0989 records live here); (b) the modifier scene object's reference array (MODREFS) — each ref[i]'s class id / superclass / and, for INode refs, the bone NAME (this IS the bone table of M.3 item 1a); and (c) the modifier's own raw chunks (MODCHUNKS). This is the working diagnostic for continued Physique decode; use it on a small corpus file (fyros/tryker armor files each have ~5 skinned nodes and ~500 verts per node — small enough for full-file dumps). Skip class buckets are split into skinned-physique and skinned-skin so the harness triage counts each independently.
A separate pipeline_max_export_shape --dump-skin <shape> mode loads a built .shape and prints its bone-name list + the first 16 vertices' SkinWeights (MatrixId[4] / Weights[4], with bone names resolved) — for validating a candidate decode field-by-field against a reference shape (both CMeshMRM and CMeshMRMSkinned expose getSkinWeights()/getBonesName() via their geom).
Status: PRODUCTION PATH LANDED (2026-07-09, §10z-dix) for the remanence consumer (piece-endpoint sampling). Shared decoder: pipeline_max_export_common/spline_shape. Full bezier tessellation (for cmb XRef spline fallback / residual mesh-eval shape-class nodes that are not remanence) is not yet wired as a mesh path — only knot endpoints are decoded.
Identity. SuperClassId SHAPE = 0x40. Common ClassIds in the Ryzom corpus:
| ClassId | Display name | Notes |
|---|---|---|
(0x0000000a, 0) |
SplineShape | Editable spline |
(0x00001040, 0) |
Line | Simple 2-knot open spline (most weapon trails) |
(0x00001065, 0) |
Rectangle | Closed 4-knot; residual mesh-eval skip class |
The Max SDK memory type for the knot assembly is SplineKnotAssy (maxsdk/include/spline3d.h): ktype, ltype, du, inVec/knot/outVec as SplinePoint (Point3 + 3 aux ints), flags. The on-disk compact form drops the aux ints and packs 52 bytes per knot (corpus-uniform across every remanence trail).
After the standard ReferenceMaker reference chunks (0x2034/0x2035) and miscellaneous shape framing, the geometry lives under a container that nests:
| ID | Kind | Meaning |
|---|---|---|
0x2900 |
leaf 4 B | uint32 numKnots |
0x2904 |
leaf 4 B | unknown (0 on every corpus spline, open AND closed — the original table had it as the closed flag; corrected 2026-07-10) |
0x290a |
leaf 52 × numKnots B |
compact knot array (§ N.2) |
0x290d |
leaf 4 B | uint32 closed (0 = open, 1 = closed) — CORRECTED 2026-07-10: 0 on every open corpus trail (remanence Lines, knot du = 1/(n−1)), 1 on every closed face-outline SplineShape (du = 1/n, the closed parametrization); nothing consumed the flag until the spline-mesh cap path, so the earlier mislabeling was harmless |
The BezierShape container that holds the per-spline containers also carries the shape's
interpolation state as sibling leaves: 0x1050 = int32 steps (6 default; 0 on the corpus
face outlines — why their caps have exactly one vertex per knot), 0x1060/0x1070 = 4-byte
words (optimize/adaptive candidates, both 1 uniformly — unproven, nothing consumes them),
0x1080/0x1090/0x10b0/0x10c0/0x10d0 = ShapeObject base params (thickness 0.1 etc.,
rendering — not consumed). Selection sets ride in 0x1020/0x1030/0x1040 (0x2700
BitArrays sized 3n/n/1).
These four siblings sit inside a per-spline container; a BezierShape may hold multiple splines (remanence requires exactly one curve with ≥1 segment, matching ShapeObject::NumberOfCurves == 1 in export_remanence.cpp).
int32 ktype // KTYPE_AUTO=0, CORNER=1, BEZIER=2, BEZIER_CORNER=3
int32 ltype // LTYPE_CURVE=0, LTYPE_LINE=1
float32 du // parameter value (typically 1.0 on the corpus)
Point3 p0 // **knot point** — matches ShapeObject::InterpPiece3D endpoints
Point3 p1 // handle (in or out; absolute or relative depending on era)
Point3 p2 // the other handle
uint32 flags // matId in HIWORD, visibility/snap bits in LOWORD
Corpus-verified fact (remanence T3, 82 files): the first Point3 (p0) is the knot point that CExportNel::buildRemanence samples via InterpPiece3D(time, 0, k, u) at piece ends (u=0 of piece 0, u=1 of each subsequent piece). After objectToLocal = objectTM · Inverse(nodeTM) (same construction as the mesh/water paths), those positions match the reference .shape corners bit-exactly. The SDK memory field order is inVec, knot, outVec; the compact on-disk packing observed here places the InterpPiece endpoint first — treat p0 as the knot for consumers, and do not assume p1/p2 semantics without a bezier-tessellation differential dataset.
Knot types on every corpus trail remanence: ktype=1 (CORNER), ltype=0 (CURVE), open (closed=0), two knots → one piece → two remanence corners.
pipeline_max_export_shape/remanence_build) — AppData USE_REMANENCE + slice/period/rollup/shift + single material + piece endpoints. Closes the 82-file remanence skip class (§10z-dix).pipeline_max_export_common/spline_mesh, 2026-07-10) — the reference exporter converts a plain shape node to a mesh via ConvertToType; open splines yield an EMPTY mesh (0 verts / 0 faces — the corpus stores literally empty CMesh shapes for them), closed splines yield a capped mesh. See § N.5.spline_mesh, not yet wired into cmb.SPLINESHAPE::decodeShapeObject walks the shape scene object's orphaned + claimed chunk trees looking for the 0x2900/0x290a sibling pattern (no new typed scene class — same peek discipline as biped_rig / physique_skin). Roundtrip of the .max is unaffected.
What Max's SplineShape::ConvertToType(TriObject) produces, reversed face-for-face from the 7
closed-spline corpus references (shape01/04/05/06/07 + fy_hom_interfaces_new + rectangle01 —
61 triangles, zero deviation, incl. the two mirrored pairs):
steps (chunk 0x1050) interior bezier(prev, ear, next) verbatim. A polygon withrectangl.cpp BuildShape): fillet 0CIRCLE_VECTOR_LENGTH = 0.5517861843). Its SimpleSpline ipblockStatus: PLANAR PRODUCTION-VALIDATED (2026-07-10). The planar projection is corpus-pinned
and applied by default in the shape exporter; the other projection types (cylindrical /
spherical / ball / box / face) are implemented in pipeline_max_export_common/uvw_map_mod but
gated off until their own corpus validation (PMB_UVW_APPLY=1 enables all types for probes).
Identity: Class_ID (0x000f72b1, 0), SuperClassId 0x810 (OSModifier). Standard old
ParamBlock (superclass 0x8) on the modifier's references, istdplug.h UVWMAP_* indices:
0 maptype (0 planar, 1 cyl, 2 sph, 3 ball, 4 box, 5 face), 1/2/3 U/V/W tile, 4/5/6 U/V/W flip,
7 cap, 8 channel (0 ⇒ map channel 1), 9 length, 10 width, 11 height, 12 axis. Gizmo PRS =
the modifier's transform-control reference; mod-context TM = the mod-app 0x2510 (§ L.1).
The mapping TM (object → map space): decompose the gizmo PRS, replace its scale with
(width, length, height) — the pblock dims ARE the gizmo box size (the UI's Fit writes
bbox·1.001 into them, e.g. Rectangle01's stored width 48.0152664 = 47.9673·1.001) — then
tm = ctxTM · Inverse(gizmo).
Planar projection: u = 0.5 − p.x, v = 0.5 − p.y after p = p_obj · tm, then
tile/flip. Sign convention pinned by the Rectangle01 GT (mag_impact_cold.max →
common/sfx/rectangle01.shape): artist-Fit planar gizmos are stored with a ~180°-about-Z
rotation (quat (0,0,−1,ε), ε ≈ 7.5e-8 — the float-π residue, and the source of the sub-ULP
UV shear visible in the reference), and the reference UVs come out u = 0.5 + x_obj/W, which
under the rotated Inverse(gizmo) (p.x = −x/W) requires the negative-bias form. The NeL
export path flips V downstream (v_final = 1 − v_max, mesh_build corner conversion), already
accounted in the above. FLOATEQ against the reference (≤ 1e-7 on every UV).
Status: CACHE-DECODE PRODUCTION (2026-07-10, §10z-quatorze). The plugin DLL is not
present on the headless host and is not reimplemented. The open design question was
whether unknown plugin chunks held re-applicable projection settings or whether the map was
already stored flat. Corpus answer (both the shape-export subset and a full
ryzomcore_graphics walk):
| Population | Files with mapext198m3.dlm |
Plugin objects | Mod-app instances | Complete 0x03e8–0x03eb |
Missing 0x2512 |
|---|---|---|---|---|---|
| Shape mapext list (exporting nodes) | 87 | 159 | 164 | 164 | 0 |
| Full graphics tree | 1158 | 2489 | 2505 | 2501 | 0 |
Four full-tree instances have an empty raw 0x2512 (no child chunks); zero parse-size
mismatches on the 2501 complete caches. The plugin object stores no projection settings.
The functional map is the LocalModData cache on the OSM derived-object wrapper.
Identity: Class_ID (0x2ec82081, 0x045a6271), SuperClassId 0x810 (OSModifier),
DLL mapext198m3.dlm ("Map extender plug-in — 3DSMax Team - UBI Soft Romania & Daniel
Raviart"). Same Class_ID the 2012 pipeline_max_rewrite_assets ReplaceMapExt mode rewrote
into Unwrap UVW by lifting this cache.
ORDERED / trivial — the only plugin-private namespace:
| Chunk | Form | Payload |
|---|---|---|
0x39bf |
container | sole custom namespace |
└ 0x0100 |
leaf, size 0 | empty on every surveyed instance — no stored settings |
Full-tree plugin-object top-level patterns (2489 objects):
| Count | Pattern |
|---|---|
| 2245 | only 0x39bf (empty 0x0100) |
| 123 | 0x2035 + 0x2045 + 0x2047 + 0x39bf (sparse refs form) |
| 106 | 0x2034 + 0x2045 + 0x2047 + 0x39bf (flat refs form) |
| 15 | 0x2150 (AppData) + 0x39bf |
Every non-empty sibling outside 0x39bf is a universal ReferenceMaker / AppData chunk,
not a plugin-specific parameter block. There is nothing to reverse-engineer in the plugin
object for export: no projection type, gizmo, tile, flip, or channel override as re-applicable
parameters. Channel comes from the cache (0x03f3), not from the plugin object.
0x2500)Standard OSM envelope (same shape as Physique / Edit Mesh / UVW Map — see also §L.1):
| Chunk | Size | Meaning |
|---|---|---|
0x2510 |
52 | mod-context TM (12×float32 + flags uint32) |
0x2511 |
24 | context bbox (min Point3 + max Point3) |
0x2512 |
variable | map-channel cache (§P.3) — almost always a leaf whose payload is still a chunk stream (container bit clear in every surveyed instance); rewrite_assets and the headless reader both re-walk the leaf bytes as a chunk stream when needed |
0x2513 |
4 | trailing uint32 (observed values e.g. 262400 — not required for UV export) |
Present on 2505/2505 full-tree mod-app instances (zero missing 0x2512 containers/leaves).
0x2512 map-channel cache (Mesh-like stream)DISPATCH sibling leaves (ids in the 0x03e8 range — a flattened single-channel Mesh map dump).
Dominant full-tree child-id sets (of 2501 complete caches):
| Count | Child ids (in file order) |
|---|---|
| 1836 | 3e8 3e9 3ea 3eb 3ec 3ed 3ee 3ef 3f3 3f5 44c 3f8 3f9 3fa |
| 309 | same + optional 3f0 3f1 3f2 before 3f3 |
| 173 | core through 44c without 3f8–3fa |
| (rest) | rare adds of 3f4 / 3f7 |
| Chunk | Size rule | Meaning | Full-tree presence |
|---|---|---|---|
0x03e8 |
4 | uint32 nVerts |
2501 / 2501 |
0x03e9 |
nVerts × 12 |
Point3 UVW vertices — UV-space, not object-space geometry (geom vert count on the base mesh is a different number; nVerts is typically ≥ face corners after unwrap seams). Values often sit near [0,1]² but tiling outside [0,1] and non-zero W are normal |
2501 |
0x03ea |
4 | uint32 nFaces |
2501 |
0x03eb |
nFaces × 12 |
(uint32 t0, t1, t2) map-face corners indexing 0x03e9 |
2501 |
0x03ec |
nFaces × 2 |
per-face uint16 companions (edge/flag packing) |
2501 — ignored for export |
0x03ed |
4 | zero in surveyed files | 2501 — ignored |
0x03ee |
33 | bit-array-like selection blob | 2501 — ignored |
0x03ef |
variable | near-zero Point3 table (unused residue) | 2501 — ignored |
0x03f0–0x03f2 |
opt. | Mesh extras | 372 — ignored |
0x03f3 |
4 | uint32 map channel (shape subset: 1 on 162 instances, 2 on 2 sky-dome nodes) |
2501 — used |
0x03f4 / 0x03f7 |
opt. | rare Mesh extras | 72 / 106 — ignored |
0x03f5 |
nFaces × 4 |
per-face uint32 (smGroup-like bit patterns when read as floats) |
2501 — ignored |
0x044c |
8 | two uint32s (unknown; constant-ish per file) |
2501 — ignored |
0x03f8–0x03fa |
opt. | named-selection / container trail | 2273 — ignored |
Functional set for export = 0x03e8 + 0x03e9 + 0x03ea + 0x03eb + 0x03f3.
Everything else is companion state that does not affect the UV channel the NeL exporter reads.
Full per-instance chunk inventory (2026-07-10, PMD §10z-seize; sizes for nVerts=n, nFaces=f):
| Chunk | Size | Content |
|---|---|---|
0x03e8 |
4 | uint32 nVerts (map verts, seam duplicates included) |
0x03e9 |
n×12 | Point3 UVW values |
0x03ea |
4 | uint32 nFaces |
0x03eb |
f×12 | per-face map-vert corner indices (3 × uint32, TVFace) |
0x03ec |
f×2 | per-face uint16 word (observed constant 0x0002 runs — edge-vis-like) |
0x03ed |
4 | uint32 (0 observed) |
0x03ee |
33 | fixed 33-byte record (size-invariant across meshes — settings blob) |
0x03ef |
varies | leading uint32 = nFaces, then per-face-ish data (undecoded) |
0x03f0 |
4 | uint32 count of the SECONDARY vert array |
0x03f1 |
×12 | secondary Point3 array (observed all-(1,1,1) heads — a channel snapshot) |
0x03f2 |
f×12 | secondary per-face corner indices (pairs with 0x03f0/0x03f1) |
0x03f3 |
4 | uint32 target map channel (1 or 2 in the corpus) |
0x03f5 |
f×4 | per-face dword — low word flags, high word matID (Face::flags-shaped) |
0x03f8 |
4 | uint32 (0 observed) |
0x03f9 |
container | one inner chunk 0x0001; per-face-ish records (undecoded) |
0x03fa |
container (26) | one inner chunk 0x0001, fixed 20-byte payload (settings) |
0x044c |
8 | version stamp {3100, 198} — matching "mapext198" |
A drop-in replacement plugin (nel/tools/3d/plugin_max/map_extender/ → mapext198m3.dlm,
Max 2010) re-registers the Class_ID, loads this stream verbatim (typed functional set +
raw-preserved companions) and replays the map in ModifyObject, so legacy scenes view/collapse/
export correctly in Max again. The modifier-level 0x39bf{0x0100} pair is the Max SDK's own
Modifier::Save framing (verified against a corpus nel_vertex_tree_paint object) — the
original saved nothing of its own. See PMD §10z-seize.
Apply rule. nFaces must equal the evaluated mesh's face count at the modifier's stack
position (after lower modifiers). A mismatch means the cache does not describe the mesh being
evaluated — refuse the apply rather than mis-index. (2026-07-10 correction, PMD §10z-quinze:
the 4 primes_racines sky domes originally read as "stale cache" were OUR defect, not the
file's — the base Sphere's ignored hemisphere parameter made us build 960 faces where Max
builds 576, so the whole Edit Mesh chain evaluated on wrong topology and the perfectly-valid
486-face cache refused. With the hemisphere path implemented the cache applies on all four
and the class is 118/118. No genuinely-stale Map Extender cache has been observed in the
corpus; the refusal rule stays as the correct safety behaviour.)
UVW vs XYZ. Spot-checks (armor, stele, sky, kitin, ligo city) show 0x03e9 coordinates in
UV-like ranges (often [0,1] for U/V, W used as a third map component). They are not a copy
of the base geom verts (0x0914). When the cache equals the base mesh's own map channel
(0x2394/0x2396 size-prefixed form on Editable Mesh), Map Extender was a no-op bake; when
it differs, it is the authoritative post-modifier map. The headless path always prefers the
cache when face counts match.
pipeline_max_export_common/map_extender_mod.{h,cpp} (MAPEXT namespace):
isMapExtenderModifier — Class_ID + SuperClassId 0x810readMapExtenderCache(modApp, out) — walks 0x2512 as raw leaf or typed container; requiresapplyMapExtender(mod, modApp, currentFaceCount, …) — refuses on face-count mismatchWired into pipeline_max_export_shape/mesh_eval as a replacing map-channel write (same
consumer shape as UVW Map / VertexTreePaint). Downstream NeL export still applies the standard
V-flip (v_final = 1 − v_max in mesh_build corner conversion) — cache UVs are Max-space.
The shape tool still emits the MAPEXT harness tag: reference .shape UVs for these assets
are garbage (the 2004 NeL plugin never implemented the modifier and leaked uninitialised
memory — design doc §9 / defective_max_files §4.4), so UV-sensitive T3 comparison against that
reference remains meaningless; recovered UVs are strictly better. Shape-subset apply rate:
118/118 exporting nodes (the 4 sky-dome refusals were our hemisphere defect, fixed
2026-07-10 — see the apply-rule correction above). The 2012 rewrite_assets
ReplaceMapExt path is the historical corroboration that 0x03e9/0x03eb are the
Unwrap-equivalent map.
Decoded across the ig light decode (PMD §10g) and the standalone-lightmapper session (PMD
§11-lm-bis). Light classes: Omni (0x1011,0), Target Spot (0x1012,0), Free Spot
(0x1014,0), Directional (0x1013,0), Target Directional (0x1015,0).
Old ParamBlock (superclass 0x8, light-object reference 0), by declared index:
| Index | Omni | Spot / Dir |
|---|---|---|
| 0 | color (Point3, 0..1) | color |
| 1 | intensity / multiplier (float, default 1.0) | intensity |
| 2 | contrast | contrast |
| 3 | soften-diffuse-edge (default 50.0) | soften |
| 4 / 5 | near attenuation start / end | hotspot / falloff (degrees) |
| 6 / 7 | far attenuation start / end | target distance / near atten |
| 9 / 10 | — | far attenuation start / end |
Object-level word chunks (2-byte values, fixed vocabulary corpus-wide):
| Chunk | Meaning |
|---|---|
0x2562 |
use (far) attenuation (corpus-validated, ig session) |
0x2570 |
cast shadows (PROVISIONAL — the only word varying per light like the artist shadow toggle; validated in aggregate by the lightmap TGA sweep) |
0x2561 |
NOT affect-diffuse (false lead, §10g) — semantics unknown |
0x2580, 0x2560, 0x2571, 0x2572, 0x2573 (dir only), 0x2590, 0x25a0, 0x2700 |
unknown flags |
0x2600 |
empty presence chunk = "ambient only" |
Exclusion list — chunk 0x2800 (container):
| Chunk | Content |
|---|---|
0x03e9 |
uint32 entry count |
0x03ea |
count × uint32 node handles |
0x03ec |
uint32 flags — 6 (= exclude illumination + shadow-cast) is the only observed value |
Handles resolve through node chunk 0x0a32 = the persistent Max node handle (a plain
uint32 on every node). Decode provenance: zo_cn_mairie's zo_mairie_omniamb (1 entry =
zo_mairie_rdc, and the freshly-regenerated reference lightmap log for exactly that receiver
lacks exactly that light) and omniamb01 (37 entries covering the couloir/sous-sol
receivers); the glow-bulb pattern (Omni*s lights excluding their own flare/globe meshes) is
corpus-wide. The include/exclude mode bit is ignored by the reference exporter's
convertFromMaxLight (every entry lands in the exclusion name set) — consumers replicating
the reference must do the same.
Per-node lightmap appdata (MAXSCRIPT utility entries): EXPORT_LIGHTMAP_LIGHT 1423062590
(default checked), LM_ANIMATED_LIGHT 41654685 (values Sun/GlobalLight/(Use NelLight Modifier) mean none), LM_LIGHT_GROUP 41654687, soft-shadow radius/cone 1423062568/9.
Projector bitmaps: no storage found on any corpus light (unused).