max_geometry_formats - ryzom/ryzomcore GitHub Wiki


title: Max Geometry Formats description: Chunk-level payload formats of the RPO/NeL patch, editable-object, modifier, and material scene classes inside .max files β€” original-plugin serializer semantics, cross-referenced against the pipeline_max implementation rules published: true date: 2026-07-10T00:00:00.000Z tags: editor: markdown dateCreated: 2026-07-06T12:44:41.047Z

.MAX Geometry & Material Chunk Formats β€” Format Reference

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.


0a. Provenance, and how this page relates to pipeline_max

Where 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

0. Container conventions

0.1 Chunk streams

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:

  • ORDERED: files carry these chunks in exactly the documented order; each is individually optional (older files omit chunks that didn't exist yet), and the original loader stopped the phase at the first unexpected ID β€” so nothing else ever appears interleaved.
  • DISPATCH: sibling chunks may appear in any order, and ids outside the documented set can occur (the original loaders ignored ids they didn't know).

0.2 Primitive encodings

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.


PART A β€” Ryzom/NeL Patch Object ("RPO")

This is the format most relevant to exporting Ryzom Core assets.

A.1 Top-level object stream (ORDERED)

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.

A.2 RPatchMesh blob (raw sequential, versioned 1–9)

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.

A.3 Version differences (reader requirements)

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.

A.4 Trailer writer/reader mismatch (RESOLVED from source)

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:

  • Files as written by the current (v9) writer carry both fields after keepMapping, in the order transitionType, selLevel. This is the authoritative on-disk layout.
  • The original loader mis-reads its own v9 files: it assigns 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.
  • Files written by genuinely old plugin builds (pre-v9 writers) may have shorter trailers than the current writer emits; the enclosing 0x08FD chunk length bounds the trailer either way.

A.5 PatchMesh chunk stream (CORPUS-VERIFIED decode, 2026-07-06; Max 3 fork 2026-07-12)

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 on the Max 4+ Ryzom ligo set (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.

Max 3 fork (Scene 0x2004 β€” Snowballs 96/96 zones, 2026-07-12): no 0x0BD1/0x0BD2 edges at all; vertex/vector containers carry only pos+flags (adjacency lists omitted); patch containers omit Type (0x0424) and Edge indices (0x042E). decodePatchMesh treats those as optional and reconstructs the edge table from each patch's V/Vec ring so exportZone bind pass sees a modern-shaped mesh. See pipeline_max_design Β§10z-snowballs.


PART B β€” Edit Patch modifier (NeL variant)

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).

B.1 Modifier stream (DISPATCH), after standard modifier base data

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.

B.2 Per-node local data

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.

B.3 Vertex mapper (chunk 0x1130 contents)

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).

B.4 Point-delta tables (undo-support structure)

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.


PART C β€” Editable Poly object (ORDERED)

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:

  • Every chunk is optional (older files omit leading ones); order is fixed. Each 0x3003 is immediately followed by its 0x3004.
  • 0x3002 carries only a count; vertex controllers persist through the scene reference system, not this stream.
  • Subobject level values: 0 object, 1 vertex, 2 edge, 3 border, 4 face, 5 element (inferred; the four base values are confirmed via the fallback mapping below, border/element by parallel with Β§D β€” verify against a sample).
  • If 0x4038 is absent, derive level from the mesh's own stored level (object/vertex/edge/face; border and element are unrecoverable). If present, it is authoritative (border β‡’ mesh edge level, element β‡’ mesh face level).
  • Current selection bit-arrays are not stored; that information lives in the per-component selection flags inside the mesh.
  • The parameter block (soft selection, subdivision, cage colors, etc.) and point controllers are not in this stream.

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).


PART D β€” Poly Select modifier

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.

D.1 Modifier stream (DISPATCH), after modifier base data

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).

D.2 Per-node local data (DISPATCH)

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.


PART E β€” Edit Poly modifier (operation-history variant)

E.1 Modifier-level chunks (DISPATCH)

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.

E.2 Per-node chunks (DISPATCH except where noted)

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.

E.3 Operation payload (inside 0x0234 or a current-op equivalent)

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).

E.4 Operation local-data payloads (inside 0x0238 / 0x0268)

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.


PART F β€” Editable Mesh object (ORDERED)

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:

  • A file contains either the legacy 0x3001 block or modern pairs, not both.
  • Nesting of the legacy set containers is UNRESOLVED: the description above places 0x2845/0x2846/0x2847 inside the 0x3001 container; 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.
  • If 0x4038 is absent, derive the level from the mesh's own level, mapping mesh-face-level to polygon (4), not face (3). If present, it is authoritative; face/polygon/element all map back to mesh face level.
  • Reference layout: version 0 (chunk absent) means per-vertex controllers start at reference 0 and everything shifts up by one to make room for a master controller; version 1 means reference 0 is the master controller and vertices follow. Only relevant if you consume animated vertices via the reference system.
  • Edge named-set bit-arrays are sized numFaces Γ— 3, indexed face*3 + side. Vertex sets size numVerts, face sets numFaces.
  • The level index is 0–2 in valid files.

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.


PART G β€” Edit Normals modifier

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).

G.1 Modifier stream (DISPATCH), after modifier base data

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).

G.2 Per-node local data (DISPATCH)

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.


PART H β€” Shared paint payloads

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.

H.1 Paint-handler payload (DISPATCH)

ID Payload
0x0000 BOOL β€” painted soft-selection active
0x0001 BOOL β€” paint deformation active

Both optional; default false.

H.2 Paint-host payload (DISPATCH)

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.


PART I β€” Material and texture formats

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:

  • data β€” carries bytes;
  • presence-only β€” empty; existence is the boolean;
  • marker β€” empty; selects a load path / migration.

I.1 Bitmap texture

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.

I.2 Standard material (original)

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.

I.3 Standard material 2

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.

I.4 Composite map

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.

I.5 Composite material

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).

I.6 Multi/sub-object material

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.

I.7 Double-sided material

0x4000 base; 0x1010 marker "ParamBlock2 present". Legacy: 0x1000/0x1001 presence β‡’ facing/back material disabled; old files also rescale translucency Γ—100 into the param block.

I.8 Vertex-color map

0x4000 base. 0x5000 (presence: use-UVW) is written by old files but ignored on read in current code β€” state lives in the param block.

I.9 Cross-class ID collision table (why global dispatch is impossible)

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

PART J β€” Biped system object (character studio)

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).

J.1 Identity and version markers

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).

J.2 Structural (figure/bind-pose) records

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).

J.3 Animation keytracks

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).

J.4 Sidedness and mirror conventions

Two independent conventions coexist and must not be confused:

  1. Storage order: every paired record (J.2's leg/arm structural halves, J.3's limb animation records) stores the right side first, left second. Undetectable on a symmetric rig β€” only an asymmetrically-edited file (a bird rig's bent forearm, a kami rig's twisted toe) discriminates it, which is why the earlier "left-first" reading survived until an asymmetric corpus case broke it.
  2. Value convention: within a side's own half, most channels (upper-arm/thigh quats, clavicle delta, finger/toe chain base deltas) are stored in a fixed "left" basis β€” the right-side values must be LR-mirrored ((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.

J.4-bis Linked (nested) COM evaluation β€” chunk 0x0112 (exact decode)

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.

J.5 Open work (documented, not yet decoded)

  • IK blend ([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).
  • Ease-curve shape (the (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.
  • Remaining smaller unknowns after the Β§10p probe: 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).

PART K β€” NeL scripted "extends" plugin objects, and the PACS output formats

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.

K.1 The "extends" object shape (shared across 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:

  • Reference 0 (superclass matches the extended class β€” 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.
  • Reference 1 (superclass 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.

K.2 Old-style ParamBlock (superclass 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).

K.3 .pacs_prim and .cmb output formats

Not 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).

K.4 nel_flare scripted plugin β€” ParamBlock2 layout

plugin 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).


PART L β€” Edit Mesh modifier (Max SDK 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".

L.1 The mod-app container (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.

L.2 Edit Mesh mesh-delta record (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 the legacy map-channel records were LOCATED by the Β§10j-sept corpus inventory (2026-07-17, pipeline_max_design): 0x0230 (12-byte stride) = created map faces (the TOPO_NTVFACES_CHUNK counterpart), 0x033b (20-byte, the same record shape as 0x0210) = tex-vertex face remap (TOPO_TVFACEMAP_CHUNK), 0x0334/0x0338 (12-byte Point3) = created tex verts (TOPO_NTVERTS_CHUNK). Strides are corpus-pinned; the semantics are inferred from the legacy counterparts and stay UNTYPED until a consumer needs them (nothing β€” ours or the reference β€” reads them).
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.

L.3 The apparent-paradox resolution (Β§10g) β€” Box + Edit Mesh stacks

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.

L.4 Modifier stream (the scene-class scope, Β§ L.4 = auth-doc Part 1)

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.

L.4.1 Selection level (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.

L.4.2 Named selection sets

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.

L.4.3 Soft-selection ("Affect Region") parameters

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.

L.4.4 Modifier flags (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.

L.4.5 Load-only behavior

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.

L.5 Local-data stream β€” modern and legacy (auth-doc Part 2)

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.

L.5.1 Modern-format local-data chunks

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.

  • Write order is V, E, F β€” even though the numeric IDs are V (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).
  • A reader dispatches by chunk id rather than by ordinal position, so read compatibility is preserved regardless of the emit order.

L.5.2 Legacy-format local-data chunks (read-only)

Corpus status (2026-07-17, Β§10j-sept): this legacy chunk set occurs NOWHERE in the corpus β€” all 4,379 Edit Mesh mod-apps, Max 3 snowballs included, carry the modern MDELTA_CHUNK form. The section is retained as SDK-derived reference for the conversion semantics (Β§ L.6, which the modern decode's clone+offset rules still lean on); no corpus tool needs a legacy reader.

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.

L.5.3 Legacy payload semantics

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.

L.5.4 Sequential deletion encoding

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.

L.6 Post-load legacy conversion

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):

  1. Clear prior delta operations. Record input vertex and face counts from TOPO_INPUTVERTS_CHUNK and TOPO_INPUTFACES_CHUNK.
  2. Configure the delta to carry two maps. Enable map slot 1 iff the texture-face count of the input mesh equals its face count (input has consistent map-1 topology).
  3. Emit vertex-clone operations from TOPO_CVERTS_CHUNK.
  4. Unwind the sequential vertex-delete list into a BitArray over the pre-deletion vertex range and emit a vertex-delete operation.
  5. Emit vertex-create operations for each entry in TOPO_NVERTS_CHUNK.
  6. For map slot 1, emit texture-vertex-create operations for each entry in TOPO_NTVERTS_CHUNK.
  7. Unwind the sequential face-delete list and emit a face-delete operation.
  8. Emit face-create operations for each entry in TOPO_NFACES_CHUNK.
  9. For map slot 1, emit texture-face-create operations for each entry in TOPO_NTVFACES_CHUNK.
  10. Convert 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.
  11. Do the same for TOPO_TVFACEMAP_CHUNK against map slot 1's face-remap.
  12. Decode TOPO_ATTRIBS_CHUNK per the bit layout in Β§ L.5.3, emitting face-change and face-smooth records as described.
  13. Emit per-vertex move operations from VERT_DELTA_CHUNK (or none, if VERT_DELTAZERO_CHUNK was present).
  14. Copy 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.


PART M β€” Physique modifier (per-node vertex-weight payload)

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.

M.1 Mod-app container tree (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).

M.2 Per-vertex record (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):

  • Stored 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).
  • A NULL 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).
  • Deformable cross-links (positive form) skip a biped COM at the segment start: a linked sub-rig's first chain bone ('Bip02 Spine') has the COM as INode parent, but the vertex deforms by the bone the sub-rig is ATTACHED to β€” the COM's own parent ('Bip01 Spine2' on the kitin queen). Rigid links (negative form) keep the literal parent node β€” kitihank/kitinagan's references keep 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).

M.3 Open items (what a follow-up session needs)

  1. Bone reference decode β€” PRIMARY DONE, cross-links open.
    • (a) The per-modifier bone table is LOCATED: it is the Physique modifier scene object's own reference array (the 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.
    • (b) The ~4 % cross-link boneRefs (small positive values, e.g. 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.
  2. Weight-to-.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.
  3. Skin modifier (undecoded). Skin's mod-app payload is completely uncharacterized. Diagnostic pattern: same 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.
  4. Bone-name array source β€” SOLVED by M.3 item 1(a): the bone names come from the modifier's reference array (each non-NULL 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).

M.4 Diagnostic tool

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).

PART N β€” Shape-superclass objects (SplineShape / Line / Rectangle) β€” BezierShape knot decode

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).

N.1 BezierShape / Spline3D chunk tree (under the shape scene object)

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).

N.2 Compact knot record (52 bytes)

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.

N.3 Consumers

  1. CSegRemanence (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).
  2. Spline β†’ TriObject mesh conversion (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.
  3. Future: cmb XRef sources that resolve to Shape (35 skipped nodes, Β§10z-ter) β€” mesh conversion now exists in spline_mesh, not yet wired into cmb.

N.4 Implementation

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.

N.5 Spline β†’ mesh conversion (cap) β€” corpus-reversed Max morph-cap rule (2026-07-10)

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):

  • Tessellation: one polygon point per knot, plus steps (chunk 0x1050) interior bezier points per CURVE segment; line-type and degenerate-handle segments contribute endpoints only. Every closed corpus spline has steps == 0 (verts == knots).
  • Projection: onto the XY plane (drop z) β€” Max shape objects are 2D entities in their local XY. Corpus-proven discriminator: shape02/03 self-intersect ONLY in the XY projection (their Newell normal is x-dominant) and real Max 2010 exports them as EMPTY meshes; every dominant-axis or best-plane projection would have capped them.
  • Ear clip: repeatedly clip the valid ear (convex in the ring orientation, no other ring vertex inside) with the minimum interior angle; ties β†’ the last minimal ear in ring order (pinned by the all-90Β° rectangle). Emit (prev, ear, next) verbatim. A polygon with negative XY area processes the REVERSED ring and emits those triangles as-is β€” the cap's winding follows the spline direction. Final triangle: the same min-angle pick decides its rotation. No valid ear β‡’ the whole cap fails β‡’ empty mesh (the shape02/03 class).
  • Cap faces: one shared smoothing group, matID 0, all edges visible.
  • Rectangle (0x1065) stores no BezierShape β€” knots generate from pblock {0 length, 1 width, 2 fillet} per the SDK sample source (rectangl.cpp BuildShape): fillet 0 β†’ 4 corner knots (w2,l2), (βˆ’w2,l2), (βˆ’w2,βˆ’l2), (w2,βˆ’l2); fillet > 0 β†’ 8 bezier knots with circle-arc handles (CIRCLE_VECTOR_LENGTH = 0.5517861843). Its SimpleSpline ipblock {0 steps, 1 optimize, 2 adaptive} is a SECOND old pblock on the object (identified by int-typed params).

PART O β€” UVW Map modifier (Class_ID 0xf72b1, per-node projection)

Status: 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).

PART P β€” Map Extender modifier (mapext198m3.dlm, Class_ID (0x2ec82081, 0x045a6271))

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.

P.1 Plugin object stream

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.

P.2 Per-node LocalModData (mod-app 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) β€” ALL 2,501 non-empty corpus instances are the leaf form whose payload is re-walked as a chunk stream (the container branch is corpus-unexercised, kept for tool-written files); 4 instances (2 collision work files) carry an EMPTY leaf β€” a never-evaluated modifier with no cache. Typed codec: builtin/storage/map_extender_cache (CMapExtenderCache, design Β§10j-huit)
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 chunks; 2,501 populated leaves + 4 empty leaves).

P.3 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.

P.4 Headless consumer

pipeline_max_export_common/map_extender_mod.{h,cpp} (MAPEXT namespace):

  • isMapExtenderModifier β€” Class_ID + SuperClassId 0x810
  • readMapExtenderCache(modApp, out) β€” walks 0x2512 as raw leaf or typed container; requires the functional set; validates face indices < nVerts
  • applyMapExtender(mod, modApp, currentFaceCount, …) β€” refuses on face-count mismatch

Wired 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.

PART Q β€” Max light objects (superclass 0x30) β€” the lightmap-relevant storage

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).

⚠️ **GitHub.com Fallback** ⚠️