This document codifies the design of the headless 3ds Max file library at nel/tools/3d/pipeline_max (namespace PIPELINE::MAX, "RYZOM CORE PIPELINE", 2012), together with its driver tools pipeline_max_dump and pipeline_max_rewrite_assets. It exists so that the session extending this library into the full headless export pipeline (see the Tutorial Tech Tree, side-quest 5) preserves the two properties the whole design is built around, rather than re-deriving them halfway and getting them subtly wrong:
Everything below was verified against the actual source (every .cpp and .h in the library was read for this document), not reconstructed from memory. File/line references are as of commit d8deff3f2-era develop.
.max files without 3ds Max. The container is an OLE2 / MS-CFB (Compound File Binary Format) document, accessed through the library's own self-contained reader/writer (storage_ole.h/.cpp, §2a) — cross-platform, deterministic, no third-party dependency. A libgsf-backed backend is retained behind a compile-time switch (WITH_PIPELINE_NATIVE_OLE, default ON) purely for cross-validation. Streams are surfaced as raw byte vectors by CStorageOleIn/CStorageOleOut; CStorageStream (storage_stream.h/.cpp) is a plain in-memory NLMISC::IStream over those bytes (seek/read for parsing, growable back-patchable write buffer for serialization).typedefs.h defines the version constants (Version3 = 0x2004 … Version2010 = 0x2012). The corpus in ryzomcore_graphics spans this whole range; the format is essentially stable across them, with per-version variations inside individual classes (e.g. CSceneImpl gains a 12th reference after Max 3)..max files exist and are not handled by either backend. If the corpus contains any, they must be detected and either supported or explicitly skipped with a count — not silently failed. (The native reader rejects a non-CFB header cleanly via CStorageOleIn::open returning false.)derived_object.*, wsm_derived_object.*, storage_file.* are empty stubs (constructor/destructor only). Placeholders, no design in them.An OLE compound document with these streams, all of which must be preserved on rewrite (this is exactly the set pipeline_max_rewrite_assets reads and writes back):
| Stream | Handling today |
|---|---|
Scene |
CScene — the real content; one version-numbered root chunk containing the scene class container |
DllDirectory |
CDllDirectory — fully parsed (list of plugin DLL description/filename pairs) |
ClassDirectory3 |
CClassDirectory3 — fully parsed (per-class: dll index, ClassId, SuperClassId, display name) |
ClassData |
CClassData — container structure known (entries 0x2100, header 0x2110 with ClassId/SuperClassId); entry body 0x2120 depends on the header and is an open TODO, kept raw |
Config |
CConfig — partially typed (0x20a0 entry table, 0x2180 ConfigScript with its recursive "meta container" value format); many leaf ids known only as unlabeled ints/strings |
VideoPostQueue |
generic CStorageContainer (raw pass-through) |
\05SummaryInformation, \05DocumentSummaryInformation |
raw byte blobs, copied verbatim |
| OLE class id (16 bytes) | the root-storage CLSID; read via CStorageOleIn::getClassId, written back via CStorageOleOut::setClassId (§2a) |
The roundtrip unit is the stream, not the file. Neither backend reproduces Max's exact OLE sector layout, so whole-file byte comparison is meaningless; per-stream byte comparison is the invariant. The Scene stream's single top-level chunk id doubles as the file version (CScene::version() asserts exactly one chunk and returns its id).
The container layer (the MS-CFB / OLE2 structured-storage envelope holding the streams above) has its own self-contained implementation, storage_ole.h/.cpp — the default, and the only one built by default. libgsf, which the 2012 code used, only builds sensibly on Linux and drags in glib/gobject; rolling our own makes the whole library cross-platform, dependency-free, and deterministic (identical output bytes on any host), which is the point.
The facade (storage_ole.h) is backend-agnostic and byte-vector based; nothing above it knows which backend is compiled in:
CStorageOleIn — open(path), getClassId(uint8[16]), hasStream(name), readStream(name, bytes), streamNames(). Reads a whole root-level stream into a byte vector.CStorageOleOut — setClassId, addStream(name, bytes) / addStreamSwap, write(path). Accumulates the root-level streams and assembles the container in one shot.CStorageStream was rewritten from a gsf adapter into a plain in-memory NLMISC::IStream: read mode over an external buffer (the bytes from readStream), write mode into a growable internal buffer. The write buffer supports the seek-back-then-restore that CStorageChunks::leaveChunk needs to back-patch chunk sizes (the reason the old harness routed container serialization through COFile temp files — NLMISC::CMemStream can't seek past its write position; the OLE layer no longer needs that workaround, though the T2 harness still uses it for its own container round-trips, see §9). So the usage pattern everywhere is: readStream(name, bytes) → CStorageStream ss(bytes) → container.serial(ss) (read); CStorageStream ss; container.serial(ss); ss.swapBuffer(bytes) → out.addStream(name, bytes) (write).
The compile-time switch is the CMake option WITH_PIPELINE_NATIVE_OLE (default ON), which defines NL_PIPELINE_NATIVE_OLE on the pipeline_max library only — storage_ole.cpp compiles the native path (#ifdef) and needs no external library; OFF compiles the libgsf path and requires libgsf + glib/gobject. Every tool uses only the facade and is backend-agnostic — libgsf is now contained entirely inside pipeline_max (a private dependency when the switch is off), never linked by the tools. nel/tools/3d/CMakeLists.txt builds the pipeline subtree when WITH_PIPELINE_NATIVE_OLE OR WITH_LIBGSF.
Native format handled. MS-CFB per [MS-CFB]; all fields little-endian by explicit byte access (host-endian-independent):
< MiniStreamCutoff (4096) is read from the mini-stream via the mini-FAT, otherwise from the big-sector FAT chain; sector s is at file offset SectorSize·(s+1). Chain-walks are bounded to guard against malformed files.< 4096 bytes go to the mini-stream, the rest to big sectors. It does not reproduce Max's physical layout (nothing does), but every stream's content is byte-for-byte preserved — which is the only invariant that matters (per-stream, §2).Validation (both backends built; cross-checked with a probe that hashes each stream's bytes + the class id, and with the corpus tester):
zonematerial — with 0 mismatches).\05DocumentSummaryInformation, big streams, and the class id; libgsf reading our output confirms the file is spec-valid).--modify-save-test (which exercises the writer through the whole scene-graph edit → whole-.max write → reload path) clean, and the pipeline_max_export_skel / _export_zone --ligo outputs are byte-identical whether the tool was built against the native or the libgsf backend (as they must be — the reader delivers identical stream bytes, so everything downstream is identical).Removing the libgsf dependency (§2a) unblocked building the whole tool set under the VS2008 x86 / Wine toolchain — the same compiler family and float model (x87, no SSE2) as the 3ds Max 2010 that produced the reference exports. The corpus's residual float-eq/x87 tier (§9, §10h) is dominated by SSE-vs-x87 double-rounding on the Linux x64 build; a build under the reference's own codegen is the way to measure how much of that gap is codegen and how much is elsewhere. This lives in a second build dir alongside the x64 one, with no Max plugin (WITH_NEL_MAXPLUGIN=OFF, WITH_PIPELINE_NATIVE_OLE=ON); see the VS2008/Wine PluginMax wiki page for the toolchain and the exact configure line.
Porting the tools to C++03. The modern tools are C++11; MSVC 9.0 is C++03. Mechanical, all kept the x64 build green: static_assert→nlctassert; std::vector::data()→a small nlVectorData(v) helper in storage_object.h (returns the base pointer, or NULL when empty — MSVC has no .data()); auto/range-for→explicit iterator loops; std::to_string→NLMISC::toString (and (long)→(sint32) casts, since MSVC long is 32-bit and has no dedicated toString overload); the one sort lambda→a functor; T x{}/T x{…}→= {}/= {…} aggregate init; a member NSDMI (bool CosineEase = false;)→a constructor; and portable #define snprintf _snprintf, pmbIsNan/pmbIsInf, and M_PI guards. _SECURE_SCL is the VS2008 default (1) and matches the NeL libs, so no ABI split.
Two latent bugs the compiler surfaced (both fixed; both also harden x64, since both were undefined behaviour that x64+glibc happened to tolerate):
CSceneClass::getChunk did m_OrphanedChunks.begin()->first with no empty check. Dereferencing begin() of an empty std::list is UB; glibc reads a harmless sentinel and falls through, but MSVC's checked iterators (_SECURE_SCL=1, on by default even in VS2008 release) raise 0xC0000417 and abort. Any class parsing a chunk after it has drained its orphans hit it. Guarded with !empty().SuperClassId by chaining to its parent's — const TSClassId CRklPatchObject::SuperClassId = BUILTIN::CPatchObject::SuperClassId; — across translation units. Because the parent is extern, its value isn't visible at compile time, so the child's definition is dynamically initialized (a runtime load), and cross-TU dynamic-init order is unspecified. x64 happened to order them correctly; MSVC initialized several children before their parents and read 0. CRklPatchObject::SuperClassId came out 0x0, so RklPatch registered under the wrong superclass, create() fell back to the GeomObject unknown, and every zone exported empty ("no NelPatchMesh"). Diagnosed via --survey: the node's object had the right class id but internal name GeomObjectUnknown. Fixed by giving all 12 such chains the literal superclass id (0x10 GeomObject / 0x1 Node / 0x200 ReferenceTarget / 0x100 ReferenceMaker) — a compile-time constant, hence static (constant) init, order-independent.Running the exes needs the release external DLLs (libxml2/jpeg62/libpng16/zlib/freetype/…) on the exe's search path and Windows-form path arguments (winepath -w); a thin per-tool wrapper under winebin/ does both, so the x64 corpus drivers (zone_corpus.py etc.) can drive the VS2008 binaries by pointing --bin at it.
Precision result (zone, the cleanest codegen-limited case). T1/T2 are byte-identical under VS2008 (the native OLE reader + typed parse are bit-exact on MSVC/x87). The x87-tier zone bricks measurably improve: in a 10-brick sample, 4 became byte-exact and the rest had consistently fewer word flips (13→9, 4→1, 6→5, …). So the SSE-vs-x87 codegen is a confirmed, real contributor to the residual — but it does not fully close it: our current-tree CZone::build source and exact build settings still differ from Max 2010's own NeL build, so byte-identity isn't reachable from the Linux side yet. The reference build is now the instrument for chasing the rest.
Decoded during the jump-emote delivery round (both pinned by a user-supplied single-variable probe file, fy_hom_emot_jump_time_at5432_max12345.max — range end 12345, slider parked at 5432):
0x20b0 (the time configuration): sub-chunk 0x0010 = frame rate (30), 0x0060 = the active segment END in ticks (160/frame at 30 fps), 0x0070 = the current slider position in ticks; the segment start lives in its own (zero) sub-chunk. The probe moved exactly 0x0060→1975200 (=12345×160) and 0x0070→869120 (=5432×160). The reference assets DO set this (fy_hom_emot_bye carries 12000 = its exact key span; skeleton files carry the 16000 default). Irrelevant to .anim export (ranges come from key spans, §10c) but part of an authored deliverable's polish — --author-jump patches it in place (patchConfigTimeRange, biped_author.cpp; a 4-byte in-place edit, chunk sizes unchanged). Earlier scans missed it because the start/end/current values sit in SEPARATE sub-chunks (an adjacent-int-pair search can't see them).\x05SummaryInformation stream, property 17 (PIDSI_THUMBNAIL), type VT_CF (71): [cb u32][cfFormat i32 = -1][clipboard format u32 = 3 CF_METAFILEPICT], then an 8-byte METAFILEPICT header (mm/xExt/yExt as u16s) and a memory WMF whose META_STRETCHDIB (0x0F43) record carries a plain BITMAPINFOHEADER DIB (observed 128×101/128×115, 24 bpp, bottom-up, uncompressed) — same shape in the 2004 corpus saves and a fresh Max 9 SP2 save. Decoder: pipeline_max_corpus_test/maxthumb.py (pure-python via maxole.py, emits PNG; locates the DIB by biSize==40 signature scan inside the WMF so record framing quirks don't matter). Corpus curiosity: the skeleton/emote thumbnails are mostly empty viewports — the artists saved with rigs hidden.CStorageChunksstorage_chunks.h/.cpp. Max chunk format:
uint16 id, uint32 size. size includes the header; bit 31 = "container" flag (chunk contains child chunks rather than leaf data).size == 0 means a uint64 follows (14-byte header total), same bit-63 container flag. The reader accepts these and downgrades to 32-bit bookkeeping (throws EStream if ≥ 2 GiB). The writer round-trips the header width per chunk (2026-07-05, see §11 "Fixed"): each IStorageObject records whether its own chunk was read with a 64-bit header (tri-state — chunks freshly authored by typed build() have no original width and fall back to their containing container's aggregate m_Was64Bit), and enterChunk(id, container, as64Bit) takes the width explicitly on write. Streams genuinely mix 32- and 64-bit headers in the corpus (~15% of files), so any coarser granularity (per-stream, per-container) corrupts on rewrite — that mistake was made once and is documented in §11.enterChunk(id, container) writes the id and a 0xFFFFFFFF size placeholder; leaveChunk() seeks back and patches the real size with the container bit. Sizes are therefore never computed from getSize() — see the defect list (§11) before ever relying on getSize().leaveChunk() returns the number of bytes skipped (unconsumed) in the chunk. CStorageContainer::serial throws EStorage if this is non-zero (storage_object.cpp:268). This is the load-bearing read-side guarantee: every byte of every chunk must be consumed by whatever object type was instantiated for it. A typed reader that under-reads cannot silently pass; it either matches the format exactly or the file fails loudly.storage_object.h/.cpp, storage_value.h/.cpp, storage_array.h.
IStorageObject (abstract, extends NLMISC::IStreamable): serial(IStream&), toString(ostream&, pad) (a readable dump — diagnostics only, never a test criterion), setSize(sint32) (called before reading a leaf so it knows how much to consume), getSize(sint32&), isContainer().CStorageContainer: an ordered std::list of (uint16 id, IStorageObject*) — order is significant and duplicate ids are normal (that's why it's a list, not a map). Reading: for each child chunk, createChunkById(id, isContainer) manufactures the object, setSize primes it, then it serials itself. Writing: iterate the list in order, wrap each in a chunk.createChunkById is the single extension point for typing a chunk: subclasses switch on the id and return a typed object; the default (and mandatory fallback for every unrecognized id) is CStorageContainer for containers and CStorageRaw for leaves. CStorageRaw is an exact byte vector. This is how unknown data survives byte-perfectly: not by special-casing, but because raw is the default and typing is the exception.CStorageValue<T> (single value; std::string/ucstring specializations sized by the chunk), CStorageArray<T> (chunk size / sizeof(T) elements), CStorageArraySizePre<T> (uint32 count prefix), CStorageArrayDynSize<T> (count prefix, variable-size elements — used for poly faces).CStorageValue<ucstring> reads/writes raw UTF-16 (size/2 chars); CConfigScriptMetaString shows the pattern for a length-prefixed, null-terminated string. When a format has optional fields, the bitfield discipline in CGeomPolyFaceInfo::serial (geom_buffers.cpp:155) is the model: parse each known bit, clear it, and nlerror on any residue — unknown variants abort rather than desync.This is the heart of the library and the thing an implementing session must not gloss. Declared on CStorageContainer (storage_object.h:116-124), with the class-level protocol implemented in CSceneClass (scene_class.cpp):
serial(in) → parse(version[, filter]) → [use / modify typed data]
→ clean() (optional: drop raw source, forces full rebuild)
→ build(version[, filter]) (reconstruct m_Chunks from typed data)
→ disown() (return ownership to m_Chunks, drop typed refs)
→ serial(out)
Ownership is tracked by one flag, m_ChunksOwnsPointers:
serial(in): m_Chunks owns everything; the object is pure storage. true.parse (CSceneClass): parent's parse runs first; then all chunks are moved onto m_OrphanedChunks and the flag flips to false. Subclasses then claim their chunks with getChunk(id), which removes them from the orphan list — in order: taking a chunk that is not at the head of the orphan list logs a warning ("chunks out of order"), because it means the class's read order no longer mirrors the file's chunk order. Chunks the class keeps unmodified are pushed to m_ArchivedChunks (deleted on clean, forgotten on disown). Chunks the class never claims stay orphaned and are re-emitted verbatim by build — that's per-chunk forward compatibility inside otherwise-typed classes (see CEditableMesh::parse, which drains a dozen known-unknown ids in their observed sequence purely to keep them in position).disown() and aborts its own parse — the object degrades to raw pass-through for this file, without failing the file. Every subclass parse body is guarded by if (!m_ChunksOwnsPointers) so that a parent's disown short-circuits the children. Codified rule: graceful degradation is per-object, and "degrade" always means "keep the original bytes", never "drop".clean: only legal after a successful parse. Deletes the raw chunks that the typed representation fully supersedes (and the archived ones). After clean, the original bytes are gone — build must regenerate them bit-perfectly from typed state. A class whose build cannot yet do that must not clean (compare CAppData, which cleans its raw entry values because CAppDataEntry::build re-serializes them, with CDllEntry::clean, which deliberately does nothing because m_Chunks retains ownership of the two value chunks it aliases).build: CSceneClass re-inserts the remaining orphans into m_Chunks, then sets an insertion cursor before the orphans; putChunk(id, obj) inserts at the cursor, so class-written chunks precede leftover orphans while both keep their internal order. putChunk calls build on any container passed through it. The putChunkValue<T>/getChunkValue<T> pair handles single-value chunks and archives them automatically.disown: nulls every typed reference, clears orphan/archive bookkeeping, flips the flag back to true, recurses. After disown, the object is again pure storage and can be serialized out. CSceneClass::disown sanity-checks that build actually ran (orphan count vs chunk count) — see the defect list for a caveat on that check.The null-rewrite property (codify as the primary test): for any file in the corpus, serial(in) → serial(out) unparsed, and serial(in) → parse → clean → build → disown → serial(out) parsed-but-unmodified (clean is mandatory — build asserts it; see the T2 note in §9), must both reproduce every stream byte-identically. The lifecycle above is precisely the machinery that makes the second path equal the first; any new typed class is a new way to break it, which is why the corpus gate (§9) reruns it wholesale.
Known deliberate normalization (whitelist, currently the only one): CAnimatable::build discards an AppData container that has zero entries (animatable.cpp:100-112). A source file carrying an empty AppData chunk will lose it on a parsed rewrite. Keep the whitelist explicit in the harness; do not let new normalizations in without listing them here.
CScene → CSceneClassContainer (scene.h/.cpp): the Scene stream's root chunk contains one child chunk per scene object. The child chunk's id is the object's index into ClassDirectory3, which in turn names the ClassId/SuperClassId/DLL. Object identity in references is the position (storage index) of the object chunk within this container.0x2032 ("OSM Derived", ClassId (0x29263a68, 0x405f22f5)) and 0x2033 ("WSM Derived", (0x4ec13906, 0x5578130e)) — derived-object wrappers that hold modifier stacks — are hardcoded in CSceneClassContainer::createChunkById as unknown-class instantiations rather than directory lookups.CSceneClassRegistry maps (SuperClassId, ClassId) → ISceneClassDesc, and SuperClassId → ISuperClassDesc. Resolution order in createChunkById: (1) exact registered class; (2) createUnknown on the registered superclass — this instantiates CSceneClassUnknown<TSuperClass>, which inherits the typed superclass, so e.g. an unimplemented material still runs CReferenceMaker::parse and gets its reference wiring parsed and rebuilt while its own chunks stay orphaned/verbatim; (3) an invalid CSceneClassUnknown<CSceneClass> fallback. This three-tier scheme is why the library can parse every Ryzom file today with only ~15 typed classes: parse coverage and byte fidelity are decoupled.CBuiltin::registerClasses (builtin.cpp) registers the typed classes and ~40 CSuperClassDescUnknown<CReferenceTarget, sclassid> entries covering every superclass observed in the corpus (controllers, materials, texmaps, param blocks, modifiers, lights, cameras, etc. — all currently routed to reference-target-typed unknowns). CUpdate1::registerClasses adds EditableMesh; CEPoly::registerClasses adds EditablePoly; BIPED::CBiped::registerClasses adds BipedDriven. A new typed class = write the class + add one registry->add line; nothing else changes. Plugin-DLL grouping is mirrored in namespaces (BUILTIN, UPDATE1, EPOLY, BIPED) with a IDllPluginDesc per DLL (update1.dlo, EPoly.dlo, biped.dlc); follow that pattern for new plugin classes (e.g. nelexport.dlu would get its own namespace if it ever needs scene classes — though NeL data itself doesn't; see §8). CBipedDriven originally landed under BUILTIN — corrected 2026-07-05 after confirming against the corpus that its ClassDirectory3 entry resolves through a real DLL entry (not the internal "Builtin" pseudo-entry), same as EditableMesh/EditablePoly's. Note the IDllPluginDesc::displayName() strings for these three deliberately don't reproduce the real per-vendor description text found in the corpus — see [[pipeline-max-vendor-naming]] (session memory) for why and how far that convention extends.CDllDirectory / CClassDirectory3 (marked "Parallel to" each other — keep them symmetrical): fully parsed into entry vectors plus a m_ChunkCache that remembers where the entry run sat among any non-entry chunks, so build re-emits entries at the original position. Both throw EStorageParse if entries are interleaved with other chunks (never observed). Internal pseudo-indices: dll index −1 = builtin, −2 = maxscript script. getOrCreateIndex appends new entries when writing new classes into a file. reset() exists to blank the directory for from-scratch writes.CSceneClassContainer::parse assigns indices by position, build's getOrCreateStorageIndex just looks up the frozen map ("Temporary 'readonly' implementation" — all four lifecycle methods). The scene-graph TODO in scene.h ("Evaluate the references tree to build new indexes") is the missing piece for adding/removing objects; until it's done, the pipeline can modify scenes but not restructure them. The export direction (max → NeL) doesn't need it; a TOML-sidecar → .max authoring direction eventually would.Typed class hierarchy as implemented (✓ = has real parse/build logic; ✗ = registered but pass-through; stub = source file exists, empty 59-line skeleton, not registered):
CSceneClass
└─ CAnimatable ✓ (AppData 0x2150, unknown 0x2140)
└─ CReferenceMaker ✓ (references 0x2034/0x2035, 0x204B, 0x2045/0x2047/0x21B0 raw)
├─ CSceneImpl ✓ (fixed 11/12 reference slots; the builtin scene singleton)
└─ CReferenceTarget ✓ (no own chunks)
├─ INode ✓ (implicit children set)
│ ├─ CNodeImpl ✓ (0x09ce version, 0x0960 parent+flags, 0x0962 name)
│ └─ CRootNode ✓
├─ CTrackViewNode ✓ (0x0110/0x0120/0x0130 per child, empties 0x0140/0x0150)
├─ CBaseObject ✓ (no own chunks)
│ └─ CObject → CGeomObject ✓ (0x08fe GeomBuffers, 0x0900)
│ ├─ CTriObject ✓ (ids 0x0901-0x0904 noted, not yet claimed)
│ │ └─ UPDATE1::CEditableMesh ✓ (known-unknown sequence kept in order)
│ ├─ CPolyObject ✓ (ids 0x0906-0x090c noted, not yet claimed)
│ │ └─ EPOLY::CEditablePoly ✓ (known-unknown sequence kept in order)
│ └─ CPatchObject → CEditablePatch (✓ registered, pass-through bodies)
├─ CParamBlock2 ✓ (superclass 0x82; §10j) ─ CMtlBase ✓ (superclass 0xc00/0xc10 mtl+texmap; §10j)
│ └─ CMultiMtl ✓ ({0x200,0}; §10j)
├─ CControlFloatLinear ✓ (0x2001; §10b/§10k) + the 9 other typed keyframers (§10b)
│ CModifier, CMtl, CStdMat, CStdMat2, CTexmap, CBitmapTex, CParamBlock ← stubs (empty, unregistered)
├─ BIPED::CBipedDriven ✓ (0x0200 bone_id+link_index; ClassId (0x9154,0), exact match
│ under the 0x9008 ControlTransform unknown-superclass fallback — "BipSlave Control" /
│ "BipDriven Control")
└─ (everything else: CSceneClassUnknown<CReferenceTarget> via superclass descs)
CReferenceMaker): two storage forms, mutually exclusive — 0x2034 (flat index array, position = reference slot, −1 = empty) and 0x2035 (leading unknown uint32, then (slot, index) pairs). m_ReferenceMap records which form was parsed so build re-emits the same form; the leading 0x2035 value is preserved. Indices resolve through container()->getByStorageIndex, live pointers are CRefPtr (non-owning, self-nulling — the scene container owns objects). Subclasses override getReference/setReference/nbReferences to give slots semantic names (CSceneImpl's 12 slots; CNodeImpl does not — node parent/name live in node chunks, and slot 1 of a node is its object, per INode::dumpNodes).CSceneImpl::parse asserts 11 references, 12 (m_TrackSetList) only when version > Version3. This is the template for all version forks: assert what the version implies, don't silently accept both.setParent maintains the in-memory child sets. INode::find resolves by case-insensitive user name — that is the instance-name hook the TOML sidecar route will use.STORAGE::CGeomBuffers, chunk 0x08fe): typed serializers exist for the tri buffers (0x0914 vertices / 0x0912 CGeomTriIndexInfo faces incl. smoothing groups / 0x0916+0x0918 and 0x0938+0x0942 secondary vertex/index pairs) and the poly buffers (0x0100 vertices / 0x010a edges / 0x011a faces with material, smoothing, and triangulation cuts), and the id→type mapping is now enabled (PMBS_GEOM_BUFFERS_PARSE is 1 since 2026-07-07, §10j; geometry parses to typed leaf arrays, gated full-corpus T2 8632/8632). CGeomObject::triangulatePolyFace (geom_object.cpp:162) converts poly faces + cuts into triangles via an edge-travel matrix, asserting triangles == cuts + 1; pipeline_max_dump::exportObj already produces valid .obj output from an EditablePoly through it. The old map-extender surgery in the rewrite tool shows the tri-object counterpart chunk pairs (0x2500→0x2512→0x03e9/0x03eb on derived objects). The chunk-level payload semantics of the editable-object streams (the known-unknown sequences CEditableMesh/CEditablePoly preserve), the RPO patch object, the patch/poly-select/edit-normals modifiers, and the material classes are catalogued in Max Geometry Formats — original-plugin serializer semantics, to be used per that page's §0a (this document's rules override its reader guidance; every typed mapping still passes the corpus gate).parse(version) with filter 0 handles the common object chunks; geometry is deferred until after the subclass has drained its own known chunks, then invoked explicitly as CTriObject::parse(version, PMB_TRI_OBJECT_PARSE_FILTER) → CGeomObject::parse(version, PMB_GEOM_OBJECT_PARSE_FILTER). The filter constants are magic per-class tokens (0x432a4da6 etc.), not flags. Separately, TParseLevel (PARSE_INTERNAL, PARSE_BUILTIN, and the commented-out PARSE_NELDATA, PARSE_NEL3D in storage_object.h:59) sketches the intended depth levels — NeL-data interpretation was always planned as a higher parse level on top of builtin parsing. The export pipeline should revive that enum rather than invent a new switch.STORAGE::CAppData (chunk 0x2150 on any CAnimatable): header 0x0100 = entry count; each entry 0x0110 = key 0x0120 (ClassId, SuperClassId, SubId, Size) + value 0x0130 (raw bytes). Entries live in a map keyed (ClassId, SuperClassId, SubId); parse fails safe (disown) on any structural surprise, and validates count against the header.
CAppDataEntry::value<T>() lazily reinterprets the raw bytes as a typed storage object by round-tripping through a CMemStream (and can re-cast, invalidating the previous pointer — one live typed view per entry). clean empties the raw copy; build re-serializes the typed view back into the raw chunk and fixes the key's Size. getOrCreate + init support authoring new entries.
All NeL export properties are AppData entries, not scene classes. The 3ds Max plugin stores them keyed by MAXSCRIPT_UTILITY_CLASS_ID (0x04d64858, 0x16d1751d) / superclass 4128 with NEL3D_APPDATA_* sub-ids defined in plugin_max/nel_mesh_lib/export_appdata.h (LOD config, accelerator/cluster flags, instance name/group, don't-export, collision, realtime light, water params, vegetable, lightmap settings, …). So the "parse the NeL data" milestone (PARSE_NELDATA) is: typed decoders for those AppData blobs on CAnimatable — zero new scene classes required. The dump tool's commented-out AppData walk (pipeline_max_dump/main.cpp:289) is the reference snippet.
pipeline_max_dump — single hardcoded file; parses directories + scene, and already performs the canonical smoke sequence: serial in → serial out to temp.bin → serial back in → parse → clean → build → disown → parse → dump, plus root-node tree dump and the .obj export prototype. This is the embryo of the per-file roundtrip check; it needs argv, per-stream byte comparison, and exception-based failure reporting instead of hardcoded paths and nlerror aborts.pipeline_max_corpus_test (2026-07-05) — the per-file corpus tester grown out of the dump tool. Takes <input.max> + optional --parse, runs T1 (structural roundtrip via CStorageContainer default) and T2 (typed parse→clean→build→disown → serialize back) per chunk-formatted stream (DllDirectory, ClassDirectory3, ClassData, Config, Scene, VideoPostQueue), byte-compares each. Writes each round-tripped stream to a PID-suffixed temp file (/tmp/pipeline_max_corpus_test.<pid>.tmp, 2026-07-05 — was a fixed shared path, which a stray concurrent invocation corrupted during development, producing a spuriously high failure rate that had nothing to do with the code under test) via NLMISC::COFile and reads it back — going through CMemStream doesn't work because its length() returns the current write position and CStorageChunks::leaveChunk needs to seek past that position to restore after patching the size field. On failure prints a per-stream one-line diff summary (first mismatch offset + 8-byte hex both sides). \05SummaryInformation / \05DocumentSummaryInformation / the OLE class-id are raw byte blobs not chunk-formatted, so byte identity is trivial and they aren't retested. Driver skel_corpus.py enumerates the corpus from the workspace directories.py files, classifies biped vs non-biped by scanning for the Biped Object UTF-16 marker, skips git-lfs stubs (files whose first 8 bytes aren't the OLE magic), and reports per-bucket totals — invoke python3 skel_corpus.py --all for the full T1+T2+T3 sweep. Wired into ctest as pipeline_max_skel_corpus (pipeline_max_corpus_test/CMakeLists.txt): self-skips (exit 77, SKIP_RETURN_CODE) when the private ryzomcore_graphics/ryzomcore_leveldesign asset checkouts aren't present, so it's harmless on any machine other than Kaetemi's; fails on any T1/T2 byte mismatch, informational-only on T3.pipeline_max_rewrite_assets — the 2012 database migration tool and the proof that parse-free structural rewriting works at corpus scale: it read every stream, optionally rewrote texture paths inside raw/string chunks (with a huge accumulated table of database renames — historical, not design), retargeted a modifier ClassId across the class directory, and wrote the OLE file back with the class id preserved. Its config is all hardcoded globals (w:\database\ mapping, WriteDummy, HaltOnIssue, overrideFF for ligo quirks). Treat it as reference code for the OLE read/write envelope (handleFile, main.cpp:1085) and the fix-up patterns; do not extend it in place.serial in → serial out → byte-compare per stream. Must pass 100%; failures are chunk-layer bugs (or 64-bit/compressed files, which must be counted and reported, not skipped silently). Status 2026-07-05 (skel corpus, pipeline_max_corpus_test over 179 .max files, 169 biped + 9 non-biped + 1 git-lfs stub): T1 green on all six chunk-formatted streams for all 169 biped + 9/9 non-biped files (169/169, up from 168/169) — the mixed-64/32-bit-header defect below is fixed. The single git-lfs pointer file (tr_mo_kami_warlord.max) is filtered out by the driver via OLE-magic check.serial → parse → clean → build → disown → serial → byte-compare, whitelisted normalizations only (§5, currently: dropped empty AppData). Rerun over the full corpus after every newly typed chunk or class — this is the non-negotiable gate that keeps typed coverage from eroding fidelity. Note: the code enforces clean() between parse and build (CDllDirectory::build/CSceneClass::build assert m_Chunks.empty()); the phrase "parse → build → disown" without clean is misleading. Status 2026-07-05: T2 green on 169/169 biped + 9/9 non-biped skel-corpus files (up from 2/169 biped on 2026-07-05, then 168/169 after the AppData-order/container-bit fixes, now 169/169 after the per-chunk 64-bit width fix — see §11). Verified against the wider ~8.6k-file corpus too: two independent random samples (400 files seed 42, 800 files seed 7 — 1200 distinct files, ~14% of the corpus) are fully clean, 0 T1/T2 failures, against a measured pre-fix baseline of ~15% failing on the same population (the mixed-header defect turned out to be far more widespread outside the skel-only corpus than the skel baseline suggested). Also fixed this session: 4 unrelated pre-existing crashes from an unregistered superclass (0x1190, "Depth of Field (mental ray)", on *_fp.max first-person-hand files) — see §11.~/core4_data) as reference — structural fields exact, floats within epsilon, mismatches triaged (plugin-version differences do exist in the reference data; the triage bucket is part of the design, decided per output type, not ad hoc). Status 2026-07-05 (second session): full-coverage skel T3 — the driver finds references for biped fauna rigs under fauna_skeletons too (previously only characters_skeletons was checked for biped-kind files, silently skipping 114 of 169) — reports size-match 169/169, and a large reference-era triage class was identified (see §10 "Reference-era mismatches"): _big/_small variants rescaled ±20% and per-race humanoid re-proportioning postdate the reference exports, so those refs are not reproducible from the current max sources by design. Known reference-data caveats, pre-triaged (Kaetemi, 2026-07-05):
_FirstFlareKeepSize). Some flare assets carried this flag mis-set in the max sources; an export/build-side hack once flipped it based on file version to make the live data look right, and was later removed because it broke assets that expected the reverse. The prebuilt reference data predates parts of this cleanup, and the max files may since have been fixed. So flare-shape sizing-flag mismatches against the reference are expected: resolve per-asset from the max source (the max file is authoritative), never by teaching the pipeline to reproduce the reference. Note the engine loader still has its own version clamp — CFlareShape::serial forces the flag false for shape versions ≤ 4 (nel/src/3d/flare_shape.cpp:77) — so what the game renders is not always what the shape file stores; compare stored values, and account for the loader clamp when judging runtime-visible differences..shape outputs of those assets contain garbage UVs; UV equality against the reference is meaningless there. The harness must auto-flag these files by DLL/class entry and route them to a dedicated bucket — either the modifier gets implemented properly (making our output better than the reference) or the assets are recorded as excluded-with-reason; silent inclusion in pass/fail statistics is not acceptable. The flagged set must be emitted as an explicit asset list report (file path, node names, modifier DLL/class entry): Kaetemi may be able to locate original exports from older live clients to serve as known-good UV references for these assets. Caveat on that caveat: such old exports may themselves derive from older revisions of the max files, so treat them as corroborating evidence for the UV channel, not as byte-exact snapshot references.calc_lm* inside nel_mesh_lib, run at export time inside Max) is replicated as a separate standalone tool downstream. Consequently T3 compares unmapped exports structurally, and the lightmapper is validated in aggregate — scene-level comparison of its output against reference lightmaps (statistical/image-level tolerance), not per-shape byte equality.toString dumps are diagnostics for triage; never assert on them.The authoritative reference for export sequence, options, and outputs is the build_gamedata pipeline (nel/tools/build_gamedata, Python — Kaetemi's 2010 port), configured by the workspace in the ryzomcore_leveldesign repository (cloned locally at ~/ryzomcore_leveldesign; the workspace is workspace/). The headless pipeline's integration target is to drop in at the 1_export stage with the same workspace contract, replacing the 3ds Max invocation while honoring the same inputs, options, outputs, and tag protocol.
0_setup.py / 1_export.py / 2_build.py / 3_install.py — under processes/<name>/. Eleven processes are Max-driven (have a maxscript/ directory): shape, anim, ig, ligo, zone, skel, swt, veget, clodbank, pacs_prim, rbank.workspace/projects.py lists projects (common/objects, common/sfx, common/characters, per-continent, per-ecosystem, …). Each project directory holds directories.py (source directories inside the graphics database, export/build directory names, tag directories, lookup paths) and process.py (which processes run, plus per-project export options — e.g. ShapeExportOptExportLighting, ShapeExportOptShadow, ShapeExportOptLumelSize, ShapeExportOptOversampling; BuildQuality == 0 globally downgrades these for fast builds). The headless exporter must consume these same options with these same names.processes/shape/1_export.py): the Python driver template-substitutes %TOKENS% (source dir, output dirs, options, log path) into maxscript/shape_export.ms, installs it into the Max user scripts directory, and runs 3dsmax.exe -U MAXScript shape_export.ms -q -mi -mip — Max then iterates every .max in the source directory itself. Incrementality and crash-resilience live in the tag protocol: a .max.tag per successfully exported source file (compared by needUpdateDirByTagLog), a max_running.tag sentinel written before each Max launch (still present afterwards ⇒ Max crashed), and a retry loop that measures progress by tag-count delta with a zero-progress retry limit of 3. The headless pipeline keeps the tag files (other stages and the incremental logic depend on them) and can discard the sentinel/retry machinery — that exists purely because Max crashes.shape_export.ms::isToBeExported skips nodes with NEL3D_APPDATA_DONOTEXPORT, collision flags, etc., and calls the NelExport plugin per eligible node. This logic moves into the headless exporter's own node filter, reading the same AppData keys (§8).shape_not_optimized/, shape_with_coarse_mesh/, shape_lightmap_not_optimized/, plus the anim directory — note that today lightmaps are computed at export time inside Max and then post-processed by lightmap_optimizer in 2_build.py. Under the unmapped-export design (§9 T3 caveats), the export stage stops producing shape_lightmap_not_optimized content and the standalone lightmapper takes over that slot in the flow, feeding the same 2_build optimizer.processes/shape/1_export.py first runs mesh_export (nel/tools/3d/mesh_export, assimp-based: .blend/.obj/.dae/.gltf) over the same source directories with the same tag protocol before falling through to the Max branch. The headless .max exporter should be wired in exactly the same shape as that branch — and mesh_export is also the natural home or template for the TOML-sidecar import route (tech tree A2).Pilot done: pipeline_max_export_skel (2026-07-05). nel/tools/3d/pipeline_max_export_skel/main.cpp produces .skel files from .max for the non-biped subset of the skeleton corpus (9 of the 178 usable files — the harness counts 179 enumerated minus 1 git-lfs stub, 169 biped + 9 non-biped; the "182" in earlier notes was a pre-harness estimate: ge_mission_*, fo_carnitree, pr_mo_phytopsy, tr_mo_electroalg, fy_mo_swarmplant, ju_mo_endrobouchea, ju_mo_sapenslaver). Traverses Bip01 in scene-order, reads sub-controller default values from raw chunks (0x2503 CVector position, 0x2504 CQuat rotation, 0x2505 first-12B CVector scale — no new typed classes required), accumulates world matrices, emits .skel matching NeL's CShapeStream + CSkeletonShape + CBoneBase v2. Structural correctness: 9/9 produce identical size to ~/core4_data reference, matching bone count/names/hierarchy and CMatrix state bits. Byte-level match: 61–96% (T3-epsilon float noise in InvBindPos rotation cells; not reproducible without replaying Max's exact arithmetic operation order). --double flag is available for depth-accumulated precision but doesn't help byte-match since Max is float throughout. Corpus tester (2026-07-05) confirms: 6/6 non-biped files with an available ~/core4_data/fauna_skeletons reference produce size-identical .skel at avg 75.8% byte-match (per-file pipeline_max_corpus_test/skel_corpus.py --t3 bucket report). The 3 fauna files without local reference are held out until the reference data catches up.
Biped detection + degraded fallback (2026-07-05). looksLikeBipedFile() in pipeline_max_export_skel/main.cpp scans the ClassDirectory3 for any of the four confirmed Biped ClassIds (below). DllDirectory-based detection is a false positive because Max loads biped.dlc even when no biped exists in the scene; ClassId-based detection also survives the display-name rename (BipSlave_Control → BipDriven_Control in Max 2022). (Historically biped files refused to export unless --allow-biped-degraded was passed, which wrote identity local transforms; as of commit d89f0a7b8 the figure-mode bind pose is reconstructed and the flag is a no-op — see "Biped figure-mode reconstruction" below.)
Confirmed Biped class identities (Autodesk character-studio MAXScript reference, 3ds Max 2023):
| Display name in .max | ClassId | SuperClassId | Role |
|---|---|---|---|
Biped |
{0x9155, 0} |
0x60 (Object) |
The biped system root — holds structure params, figure-mode state, per-bone table |
Vertical/Horizontal/Turn |
{0x9156, 0} |
0x9008 (ControlTransform) |
COM/body controller attached to Bip01's transform track |
BipSlave Control (pre-2022) / BipDriven Control (2022+) |
{0x9154, 0} |
0x9008 (ControlTransform) |
Per-body-part TM controller; every non-COM biped bone has one as its getReference(0) |
Biped SubAnim |
{0x6b147369, 0x078c6b2a} |
0x9003 (ControlFloat) |
Sub-anim override; referenced as slot 2 of each BipDriven Control |
Biped Object |
{0x9125, 0} |
0x10 (GeomObject) |
Visual per-body-part geometry, attached as getReference(1) of each biped INode |
Each BipDriven Control's chunk 0x0200 (8 bytes = 2 uint32s) is (bone_id, link_index) per Autodesk's biped.getIdLink(node) return. The MaxScript-facing IDs in the SDK docs are 1-based (1=#larm .. 22=#prop3); the internal IDs stored in 0x0200 are the same enum shifted to 0-based. Confirmed by dumping each biped node's controller in fy_hom_skel.max:
| Internal ID (0x0200) | MaxScript ID | Name | Observed bones |
|---|---|---|---|
| 0 | 1 | #larm |
L Clavicle (link 0), L UpperArm (1), L Forearm (2), L Hand (3) |
| 1 | 2 | #rarm |
R Clavicle/UpperArm/Forearm/Hand |
| 2 | 3 | #lfingers |
L Finger0..L Finger42 |
| 3 | 4 | #rfingers |
R Finger0..R Finger42 |
| 4 | 5 | #lleg |
L Thigh, L Calf, L Foot |
| 5 | 6 | #rleg |
R Thigh, R Calf, R Foot |
| 6 | 7 | #ltoes |
L Toe0.. |
| 7 | 8 | #rtoes |
R Toe0.. |
| 8 | 9 | #spine |
Bip01 Spine, Spine1, Spine2 |
| 10 | 11 | #head |
Bip01 Head |
| 11 | 12 | #pelvis |
Bip01 Pelvis |
| 15 | 16 | #footprints |
Bip01 Footsteps |
| 16 | 17 | #neck |
Bip01 Neck |
| 17 | 18 | #pony1 |
Bip01 Ponytail1/11/12 |
Empirically observed IDs in the corpus reach up to 28, suggesting the internal biped has a few more IDs than the 22 documented (likely twist bones or extension ids); worth cataloguing in a full sweep when finger/toe reindexing gets addressed.
Biped bone rotation/position inheritance is not INode-parent inheritance. From the character-studio SDK docs, biped bones inherit differently from what the scene graph says:
That means the current walkNode(bip01, -1, parentWorld, …) in pipeline_max_export_skel — which just multiplies each node's local TM by its INode-parent's world TM — is structurally wrong for biped bones the moment the biped rig is non-trivial (e.g. clavicles skip a level of inheritance vs Max's INode chain). The right shape is:
BipDriven Control as its TM controller), read chunk 0x0200 to identify (bone_id, link_index).(id, link) in the root Biped (0x9155) controller's per-bone table — the transforms Autodesk stores are what INode::GetNodeTM(time) returns in figure mode, and each is already the world transform; the .skel bind pose derives from that plus the biped inheritance rules above (used to reset the root and compute InvBindPos).UnheritScale on the .skel bone must be set to true for biped bones whose INode parent is also a biped bone (from getNELUnHeritFatherScale in plugin_max/nel_mesh_lib/export_skinning.cpp).The per-bone table lives in the root Biped controller across chunks 0x000b (80B), 0x000d (72B), 0x000f (232B, dual snapshot), 0x0010 (936B, dual snapshot) — decoding the (id, link) → transform mapping in 0x0010 is the actual open work.
Biped figure-mode reconstruction (2026-07-05, pipeline_max_export_skel commit d89f0a7b8). The biped bind pose is now reconstructed — arms and legs point correctly, not identity. --allow-biped-degraded is a no-op (the tool always reconstructs). The exporter reads the root Biped (0x9155) object's per-limb record chunks and supplies correct local transforms for the decoded roles, falling back to straight-chain inheritance for the not-yet-decoded ones. Corpus accuracy over the full 169-biped set (55 files with a local reference, 4014 bones):
| Metric | Reconstruction | First-order approx | Identity baseline |
|---|---|---|---|
dpos exact+close (< 0.02) |
78.2% (647+2614 / 4171) | 67.3% | ~1.2% |
drot exact+close (< 0.02) |
63.3% (2242+398 / 4171) | 0% (all identity) | 0% |
| Avg dpos error | 0.039 | 0.045 | ~0.16 |
| Files size-matching ref | 29 | 0 | 0 |
(Bone-count and percentages as of 2026-07-05 after the clavicle-position and finger/toe-nub decodes; see below. T1/T2 stood at 168/169 when this table was made — the one fail was the mixed-64/32-bit-header file, since fixed; current status is 169/169, see §9.) Non-biped T3 unchanged (9/9), no crashes across the biped corpus — including the *_fp.max first-person-hand files, which previously aborted the whole process on an unregistered superclass (0x1190, "Depth of Field (mental ray)"); fixed by adding it to CBuiltin::registerClasses alongside the other ~40 unknown-superclass pass-throughs (builtin.cpp), same pattern as every other unimplemented superclass in the corpus. pipeline_max_corpus_test/skel_corpus.py now reports drot accuracy alongside dpos.
The decode — how it works. Everything the reference exporter does reduces to one input: node.GetNodeTM(time), the figure-mode world matrix per bone (see §10 buildSkeleton). Ground truth for it is the reference .skel's InvBindPos inverted. The reconstruction rebuilds those world transforms:
(x,y,z) → (x,-z,y). Rotation basis change C = [[1,0,0],[0,0,-1],[0,1,0]]; a stored Max-row-vector 3×3 M becomes the NeL local rotation R_nel = C · Mᵀ (columns I=(m0,-m2,m1) J=(m4,-m6,m5) K=(m8,-m10,m9)), then CMatrix::getRot(). Verified bit-exact on the finger matrices.I·(child_wp−self_wp)/|…| = +1.000 for every single-child bone, all templates). So straight-continuation bones (Spine1/2, Neck, Calf, Forearm, finger/toe/ponytail sub-links) have identity local rotation — matching the reference drot ≈ (0,0,0,±1) — and only direction-changing joints carry real rotation. This is why "read the joints, inherit the rest" works.(0,0,height)). Bip01's world rotation is the canonical CQuat(0,0,-√½,√½) (−90° about Z), constant across the corpus — note this differs from the .skel DefaultRotQuat, which is identity for the root (reset) and uses the decompMatrix convention, not the getRot()/world convention the joint decodes are validated against. Pelvis local rotation is the constant (0.5,0.5,0.5,-0.5) (COM→pelvis frame reorientation), so pelvisWorld = Bip01World · (0.5,0.5,0.5,-0.5).| Joint | id.link | Chunk[off] | perm | sign | Frame |
|---|---|---|---|---|---|
| Thigh | 4/5 .0 | 0x0069[2] |
(2,3,0,1) | (+,+,−,+) | pelvis-relative |
| Foot | 4/5 .2 | 0x0069[28] |
(2,0,3,1) | (+,−,−,+) | |
| UpperArm | 0/1 .1 | 0x006a[2] |
(1,2,3,0) | (+,−,+,−) | pelvis-relative (~4° off — superseded) |
| Head | 10 .0 | 0x0064[0] |
(0,1,2,3) | (+,+,+,+) | pelvis-relative |
World rotation = pelvisWorld · q (pelvis-relative) or q (world). Local rotation = parentWorld⁻¹ · worldRot, fed to walkNode so InvBindPos and default tracks derive exactly as for non-biped bones.
5. Right side = left mirror, applied in the pelvis-relative frame only (the one frame where the mirror is the clean (x,y,-z,-w) rule). (2026-07-06: refined — the mirror is real but each side reads its own half, whose right-side values are mirror-encoded; the foot needs no mirror at all. See below.)
6. Finger/toe bases store a full 4×4 local matrix (relative to Hand/Foot). Arm record 0x0010 layout: [0..15] arm params (incl. [10] = clavicle Z-offset), [16]=nFingers, then per finger [nLinks][4×4 matrix (16 floats, Y-up)][nLinks length floats]; consumes exactly 117 floats per half (L then R). Generalizes across all templates (5 fingers × 3 links); simpler bipeds without fingers correctly lack the chunk. Decoded via the §1 conversion → exact local pos+rot.
SDK-confirmed hierarchy (Autodesk character-studio Biped Hierarchy page, matches the decode): rotation inherits from COM for spine-base/neck-base/upper-arms/upper-legs/feet, from last spine link for clavicle, else from parent INode; position from COM for spine-base, from pelvis for upper-leg, from last spine link for clavicle, else parent INode. DOF: pelvis 2, clavicle 2, knee/elbow 1, finger/toe non-base 1 — the 2-DOF joints (getClavicleVals/getPelvisVal) and hinge joints (getHingeVal) are the still-undecoded ones. Leg link order is Thigh, Calf, HorseLink, Foot — 4-link legs (legLinks=4) put Foot at link 3, not 2 (the link==2 Foot check must generalize to "last leg link" for horse/mount rigs).
Assimp roundtrip validation (2026-07-05). pipeline_max_export_skel --gltf <path> also writes a glTF 2.0 skeleton-only file (JSON, no meshes or skinning) storing the REAL local transforms (not the root-reset values, so the InvBindPos noise pattern is preserved). mesh_utils/assimp_skel.{h,cpp} adds exportSkels(): finds the bone root via scene_meta TBoneRoot or a case-insensitive "Bip01" descendant, walks the aiNode tree in mChildren order, decomposes aiNode.mTransformation into T/R/S, and emits .skel via the same identity+setRot+setPos+invert chain as the direct path. Cross-path parity over the 9-file non-biped corpus: 83.7–95.0% byte match A-vs-B, with the ~5–16% delta from float precision loss through the glTF → aiMatrix4x4 → Decompose cycle. Notably B is often closer to the reference than A for large fauna files (68% vs 62%) — assimp's Decompose renormalization happens to track Max's arithmetic better than direct CMatrix multiplication. The glTF is loadable in Blender as an armature/empty hierarchy, making this the artist path off Max as well.
Method note (how the decodes were found). The chunk offsets/perms/signs above were located by a family of scratchpad tools driven off ground truth (biped_analyze.cpp dumps every biped chunk's floats + emits per-bone #GT world transforms from the reference .skel; Python matchers scan for 4×4 windows / quaternions matching a bone's world or pelvis-relative rotation under permutation+sign, then verify generalization across ≥4 templates before trusting a decode). The generalization gate is essential: an earlier attempt found chain-root Z-offsets at fixed indices in 0x000f/0x0010 that were bit-exact on fy_hom_skel but wrong on ma_hom_skel/tr_hof_skel — the record layout shifts per-rig, so fixed indices into variable-length sections do not hold. The decodes that shipped are all at offsets before any variable-length section (the limb-record headers), which is why they generalize.
Clavicle position decoded (2026-07-05, commit a5e3e848f). Clavicle attaches to the last spine link at a pure Z (side) offset stored in the arm-record header at 0x0010[10] (exact on fy_hom/fy_hof, within the 0.02 "close" tolerance on ma/tr). Set dpos = (0,0,±0x0010[10]) (mirror z for R). Corpus biped dpos 75.1% → 77.6% exact+close.
Finger/toe end-effector nub ids catalogued and decoded (2026-07-05). The "empirically observed IDs reach up to 28" note above is now resolved for the finger/toe case: scanning chunk 0x0200 across all 9 templates with a local reference (fy/tr/zo/ma male, fy/tr/zo/ca female, ca male) found four stable extension ids beyond the 22 MaxScript-documented groups, all end-effector marker dummies with no children (so the "aim at child" rule that gives straight-chain bones identity rotation doesn't apply to them):
| Extension id | Role | Required local drot |
|---|---|---|
| 22 | R finger-tip nub (link = which finger, 0-4) | (0,0,-1,0) (180° about Z) |
| 23 | L finger-tip nub (link = which finger, 0-4) | identity — already exact via the default |
| 24 | L Toe0-tip nub | (0,0,-1,0) (180° about Z) |
| 25 | R Toe0-tip nub | identity — already exact via the default |
Ids 26-29 were also observed (26 = Tail on ca_hom/ca_hof, 27 = a second head-attach dummy, 28 = Ponytail1 on a second attach point, 29 = a neck accessory dummy on ca_hom) but aren't decoded — no reference mismatch currently traces to them, so there's nothing to validate a decode against yet (see the "type only what you can prove" rule, §12.2). pipeline_max_export_skel now special-cases ids 22/24 to the constant 180°-about-Z local rotation; corpus drot exact+close rose 55.6% → 63.3% (2242+398 of 4171) with no dpos/T1/T2 regression.
UnheritScale controller-flag reader: confirmed not exercised, deferred by design. Checked every bone in the 6 non-biped corpus files that have a local reference: UnheritScale is false for all of them (never true). Building a controller-inheritance-flag reader (INHERIT_SCL_X|Y|Z, Control::GetInheritanceFlags()) would therefore be pure speculation with zero corpus signal to validate against — per §12.2 ("type only what you can prove"), left as the current default (false for non-biped bones) rather than guessed at.
4-link legs (HorseLink) generalized (2026-07-05). The link==2 Foot check is now link == g_maxLegLink, where g_maxLegLink is computed once per file by scanning every BID_LLEG/BID_RLEG bone's link index and taking the max (falling back to 2 if no leg bones are found at all). This is a strict generalization — byte-identical output on every 3-link rig in the corpus today (verified) — that also handles a hypothetical 4-link legLinks=4 mount/horse rig correctly, should one enter the corpus later; no such rig exists today so this specific path stays unverified by a reference file.
Biped structure records fully decoded (2026-07-05, skel-corpus continuation session). The previous session's conclusion that thigh/toe positions are "procedurally computed, not stored" was wrong — they are stored, in the per-limb structure records, and the earlier failure to find them was a reference-era artifact (below). The full record family on the 0x9155 Biped system object:
| Chunk | Record | Layout |
|---|---|---|
0x000b |
spine struct | [0]=?, [1..n] per-link lengths, 4×4 base-attach matrix; n = floats − 17 |
0x000d |
pelvis struct | [0..1], identity matrix (pelvis fwd-offset not here — still open) |
0x000e |
tail struct | same shape as spine |
0x0013 / 0x0014 |
ponytail1 / ponytail2 struct | same shape as spine |
0x000f |
leg struct | per side half: [0..9] params ([1] = thigh side-offset, pelvis frame (0,0,±v)), [10]=nToes, per toe [nLinks][4×4 matrix][nLinks lengths] |
0x0010 |
arm struct | [0..15] params ([7] = clavicle angle, [10] = clavicle Z-offset), [16]=nFingers, per finger [nLinks][4×4][lengths] |
0x0067/0x0068/0x006d/0x006e |
spine/tail/pony1/pony2 per-link angles | [0]=int count, then (a1,a2,a3) per link |
0x0064 |
head record | [0..3] head quat (known), [7]=int count, [8..] neck per-link angle triples |
0x006c |
COM record | [4..6] COM position (Y-up), [8..11] COM world quat (Y-up; NeL = (−x, z, −y, w)) — not always the canonical −90°Z (e.g. tr_mo_balduse), so this replaces the constant |
0x0258..0x0261 |
snapshot bank | byte-mirrors of 0x0064..0x006d |
Conversions (empirical, corpus-validated): chain-base matrices (spine/tail/pony) 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 toes = mirror of the left half's data (the R half's own matrices use yet another basis — don't parse them). Per-link angle composition: R = Rx(a3) · Rz(−a1) · Ry(a2); chain base local = matrixRot · R(angles[0]). Chain link positions come from the record lengths (the links' own 0x096c dims don't track figure spacing — ca_hom ponytails, every tail); finger/toe link positions keep 0x096c (which does track figure rubber-band scaling that the record lengths don't — tr_mo_kitin_queen's ~24× scaled fingers). Chain end-effector dummies: id 26 (tail nub) / 28 (pony1 nub) / 29 (neck dummy) have the constant local rotation (−√½,−√½,0,0) on every corpus instance; id 27 (head dummy) identity. Neck base sits at the last spine link's stored length.
Multi-biped files. tr_mo_kitin_queen carries two bipeds (Bip01 + Bip02 nested under Bip01 Spine2). Every BipDriven Control and every COM Vertical/Horizontal/Turn controller (ClassId {0x9156,0}) references its own 0x9155 system object as getReference(0) — the exporter now keeps all figure state per-system (SBipedRig), and COM nodes (Bip01/Bip02) take their world transform from their rig's COM record.
Skeleton LODs (byte-structure fix). The reference .skels carry multiple LODs built from NEL3D_APPDATA_BONE_LOD_DISTANCE AppData on the nodes (e.g. finger bones disabled at distance). The exporter now reads that AppData (string-valued script entries, matched by SubId 1423062615) and reproduces CSkeletonShape::build exactly: father-clamped distances, one LOD per distinct distance. This was the missing 88 bytes on otherwise size-matching files — size-match is now 169/169. Also fixed: the reference exporter sets UnheritScale=true on the COM node (checked against ref bytes).
Reference-era mismatches (the trap that hid the stored thigh). 0x000f[1] bit-matches the reference thigh offset on 91/169 files — including every base monster rig. The 78 mismatches are exactly: the _big/_small monster variants (stored values differ from ref by precisely ×1.2 / ×0.8 — the max rigs were rescaled ±20% after the refs were exported; the refs equal the base rig), the humanoid races (ma/tr/zo/ca refs all carry fy-era figure params bit-identically; the per-race max files were re-proportioned afterwards), and tr_mo_kitin_queen (×~2). Per the §9 T3 triage rules, the max file is authoritative; the corpus report should learn to bucket these separately (an "era-matched" filter keyed on the thigh bit-match is what the analysis tooling uses today).
Corpus accuracy after this round (full T3 coverage — the harness now finds references for biped fauna rigs under fauna_skeletons, so all 169 biped + 9 non-biped files are measured, up from 55): biped size-match 169/169, dpos 4324 exact + 4107 close / 11120 (~39% bit-exact; the era-mismatched files above account for most of the rest), drot 7006+538/11120, avg byte-match 68.1%; non-biped dpos 222/222 all bit-exact, byte-match 81.9%. On era-matched files the remaining error is concentrated in: clavicle/hand rotations, calf/knee (+foot local drot), the generic PRS path (prs:other), Footsteps, and a small pelvis forward-offset.
Two PRS-path defects fixed (2026-07-05, same session — these were most of the remaining drot error):
getLocalTransform now conjugates the TCB quaternion (0x2504). The reference exporter never hit this because it read GetNodeTM() matrices, not controller values; our earlier reads passed on exactly the self-conjugate rotations (identity and any 180°), which is what made the bug look like scattered per-bone noise instead of a convention. After the fix the non-biped corpus is 222/222 bit-exact on both dpos and drot (byte-match 81.9% → 90.9%; the rest is InvBindPos float noise), and prs:other went from 422/508 era-matched bones wrong to 7.name tag marker including tilted-COM rigs (tr_mo_c03), and it makes Footsteps' drot exact on all 169 files (its controller value is identity ⇒ local = COM⁻¹ = the constant (0,0,−√½,−√½)).Also landed: clavicle base rotation = 180° about (cos φ, 0, sin φ), φ = π/4 + 0x0010[7]/2 (right side = (−x,−y,z,w) mirror) — exact on planar rigs, −87% clavicle drot overall. Corpus after this round: biped drot 8368+413/11120 exact+close (err 0.1037), dpos unchanged (4324+4107), non-biped fully exact.
COM V/H/T displacement decoded (2026-07-05, same session). 0x006c[0..2] is the COM node's current Vertical/Horizontal/Turn displacement relative to the figure COM, in the COM's local frame: Bip01World = figureCom + ComRot·(d0, −d2, d1) — verified exact on every era-matched corpus file (the z-component discriminated by tr_mo_kitifly/kitikil, the big cases by ca_spaceship's 1.28 shift). The Pelvis stays at the figure COM, so its local position is −(d0, −d2, d1) — pelvis dpos error went 2.69 → 0.001 over the era-matched set (only the scaled mektoub_selle variants remain).
Knee/elbow/horse-ankle hinges decoded (2026-07-06, from the character-studio SDK reference). The SDK's IBipMaster confirmed the DOF model (GetHingeVal — knee/elbow 1-DOF; GetHorseAnkleVal; GetClavicleVals returning exactly two floats; the V/H/T key layouts matching the COM-displacement decode). The hinge figure values sit at the head of each side's pose-record half (halves = chunk floats / 2, per side L then R):
| Hinge | Slot | Local rotation |
|---|---|---|
| Knee (calf, leg link 1) | 0x0069[0] |
rotZ([0] − π) (stored as interior angle) |
| Horse ankle (leg link 2 on 4-link legs) | 0x0069[1] |
rotZ([1]) (as-is) |
| Elbow (forearm, arm link 2) | 0x006a[0] |
rotZ([0] − π) |
Bit-exact across the era-matched corpus (calf/forearm/horse-link drot now 0.000 on humanoids, kami, mounts; the humanoid foot locals follow for free since foot world was already decoded). Known deviations: the kitin family's calf is off by a constant 5.00° and the asymmetric-edited bird rigs (tr_mo_c01/kazoar/lightbird/yber — the same 4 files as the L-toe position anomaly) have a 0.5° L-forearm deviation; both look like post-export figure edits. Also mapped: 0x0100 on the system object = the SetNumLinks table per keytrack ([8]=spine, [9]=tail, [17]=pony1, [22]=nFingers, …).
Differential-dataset decode round (2026-07-06) — the figure-IK "not stored" list was wrong; nearly everything IS stored. A Max 9 differential dataset (140 single-change .max variants generated by biped_dataset_gen.ms: a canonical reference biped + one structural param or one bone's figure pose changed per file, with biped.getTransform world pos/rot ground truth for every bone logged to manifest.txt — at ~/biped_dataset) pinpointed each edit's exact storage floats by per-chunk float diff against the baseline. The Max 9 chunk layout is byte-size-identical to the corpus era, and every decode below was re-validated against the corpus references (numeric hypothesis search over the 24 signed-permutation rotations × reference frames, then the exporter + T3). Corpus-facing results:
0x006a[28..31], L Hand at [half+28..]; R Clavicle at 0x0010[6],[7], L at [half+6..7]). The earlier left-first assumption was undetectable on symmetric rigs (L == mirror(R)) and is what broke the asymmetric-edited ones (bird-rig L forearm 0.5°, kami_guide_4's L Toe0 180° flip — both resolved). Each side now reads its OWN half; the right half's values are mirror-encoded (left convention), so right-side decodes wrap in the LR mirror (x,y,-z,-w) — except the foot, whose halves store true per-side absolutes.[28..31] of the side's 0x0069 half is the ABSOLUTE foot orientation in the COM-relative Y-up frame: R_foot = R_com · C · qmat(s)ᵀ, both sides direct, any orientation (planted, tilted, yawed — exact on the bird/crab/kitin free-foot rigs). The old "world quat, planted only" perm had silently baked the canonical −90° COM yaw into its fixed signs.∓(π/2 − ε) about its X (ε = 0.0008 rad, a fixed biped-internal constant, invariant across rig structure/pose/scale, confirmed corpus-era); the stored quat at [28..31] of the side's 0x006a half is a local post-multiplied delta: R_hand = R_forearm · Rx(∓(π/2−ε)) · C · qmat(s)ᵀ · Cᵀ (right side: LR-mirror the delta). [39..42] holds a second, redundant quat (absolute-when-edited; not needed).L: R = R_pelvis · Rx(π) · qmat(s)ᵀ · Ry(π), R: R = R_pelvis · Ry(π) · qmat(s)ᵀ · Rx(π), s at [2..5] of the side's half (+ leg-head shift n/a for arms).rel = Rx(−a) · Rz(−ε) · B(b) with a = armRecord[6], b = [7] per side, B(b) = the known 180°-about-(cos(π/4+b/2), 0, sin(π/4+b/2)); right side = LR mirror of its own-half decode. The same ε as the hand.[46+10k..49+10k] = chain k's base-link delta quat (local post-multiply qmat(s)ᵀ on the record-matrix base local; right side mirrors the composed local), [54+10k], [55+10k], … = the non-base links' ABSOLUTE bend angles in radians, local rotation Rz(angle) (this replaces "straight-chain" finger/toe links — the humanoid default curl 0.2333 rad was always there).[4..7], foot at [30..33], toe blocks at [48+10k]. Knee [0] and horse-ankle [1] do NOT shift.[0] = int count of angle floats (3/link), [1..count] = the (a1,a2,a3) triples (same composition), then a 4×4 base-attach matrix. The head-record copy at 0x0064[8..] is stale on 48/169 corpus files (tr_mo_arma/bul, the ge_ kami pair); the exporter now reads 0x0065 with 0x0064 as fallback.0x0066[0..3], a quat) is stored but also not applied to the pose; identity on all 169 corpus files.0x0064[0..3] unchanged (pelvis-relative, identity component order).Follow-up fixes, same day: the corpus-era Footsteps node is a plain PRS child of the COM (not a BipDriven with BID_FOOTPRINTS like later plugin versions), so the ground patch identifies it by name; the reference footsteps sits at world (Bip01.x, Bip01.y, ground), where ground defaults to the z=0 plane / the toe attach height (mm-equivalent; toe-z is bit-exact on fy_hom and fy_mo_frahar) — EXCEPT ~30 rigs whose marker was moved (ca_spaceship's 7.73, capryni, mektoub, c05): that moved ground level is genuinely not stored anywhere in the 0x9155 records (re-verified with value scans; it likely lives in footstep animation keys), left open. The clavicle position is the full 3-vector (armRecord[9], [8], [10]) per side in the parent frame (x/y are zero on humanoids — which is why [10] alone had looked sufficient — but real on monster rigs: tr_mo_c05 (0.068, 0.103), fy_mo_frahar, kamique; the left half stores (−x, −y, z)). Also fixed: skel_corpus.py --t2 alone silently ran nothing (the --parse flag rides on the T1 invocation), and the pipeline_max_skel_corpus ctest now gates T3 accuracy (--gate-t3: biped size-match 100%, drot exact ≥ 97%, dpos exact+close ≥ 72%, non-biped bit-exact) instead of being informational-only.
Corpus T3 after this round: biped drot 11058+3/11120 exact+close (99.4% exact, err 0.0033) — from 8368+413 (75.3%) before; dpos 4408+4101/11120 exact+close (err 0.0262) from 4386+4053 (0.0401); byte-match 69.0→75.6%; size-match 169/169; T1/T2 stay 169/169 + 9/9; non-biped untouched (222/222 bit-exact both). Remaining drot: the prs:name marker class (28, markers not COM-parented — pre-existing), 13 L finger bases on asymmetric-based rigs, and ~1 outlier file per role (genuinely divergent/era).
Float-level field validation (2026-07-06, follow-up). skel_corpus.py now parses every stored .skel field and compares at float level, not just via whole-file bytes: strict bit-exact counters for dpos/drot (drot double-cover aware)/scale/lod/aux fields, father + UnheritScale equality, and a per-element ULP histogram for InvBindPos (state bits compared first). This immediately surfaced a real defect the aggregate byte-match percentage had hidden: UnheritScale was wrong on 862 corpus bones — the reference exporter's getNELUnHeritFatherScale sets it when the parent is a biped node, so plain PRS bones hanging off biped bones (Footsteps, the name/cheveux/weapon-box markers) unherit too, not only biped bones themselves. Fixed; all field classes now 100% (father, unherit, lod, aux, InvBindPos state bits) and gated in ctest. Calibration facts the numbers established: the epsilon-"exact" dpos/drot counts are NOT bit-exact — only 289/398 of 11120 biped (47/32 of 222 non-biped) bones are bit-identical, because the reference exporter re-derived values through Max's matrix decompose, which injects low-ULP noise even where our inputs are the stored controller values verbatim; the reference dscale on _big/_small rigs carries the old uniform figure scale (~1.034 — reference-era class, max file authoritative), and the rest of the scale deltas are ±1–2 ULP decompose noise around 1.0. The biped InvBindPos ULP histogram (64% of elements >256 ULP) is dominated by near-zero matrix cells where tiny absolute noise is a huge ULP distance — read it together with the byte-match percentage, not alone.
Era-divergence warning — verified the hard way, don't re-try: the toe/finger base matrices do NOT reliably share their encoding between the two halves — decoding each side from its own half regressed 250 drot bones corpus-wide; the shipped code parses both halves but decodes only the right one (left = direct, right = LR mirror), keeping only the pose-block deltas per-side. The Max 9 dataset cannot discriminate this (its symmetric baselines make both schemes coincide).
Encode-direction cross-validation (2026-07-06, run completed — see §10e). pipeline_max_export_skel --maxscript <out.ms> emits a Max 9 MAXScript that regenerates the file's biped from the decoded reconstruction (biped.createNew with structure detected from the records — height = 0x000c/0.113253, ankleAttach = 0x000f[8]/([8]+[9]), link counts from the id/link table — then two biped.setTransform passes forcing every bone's figure-mode world transform). gen_biped_regen.py assembles the whole-corpus script (147 rigs; the 22 two-biped kitin rigs are skipped in v1). Kaetemi runs it in Max 9 (units = meters): the output manifest.txt (differential-dataset format) validates decode→Max acceptance, and the regenerated .max files' 0x9155 records diff against the originals to confirm each record's decode bidirectionally — also yielding a fresh-format (0x0115 == 0) corpus with known-good legacy twins for settling the L-half base-matrix encoding and the remaining open slots.
The figure-version marker 0x0115 (2026-07-06). The "era" here is per-FILE, not per-plugin, and it is detectable: chunk 0x0115 on the 0x9155 system object is int 3 on 168/169 corpus rigs (authored in Max 3, upgraded to Max 9) and int 0 on figures created fresh in Max 9+ (all 139 differential-dataset files — and exactly one corpus rig, tr_mo_kitin_queen, which is also every "single outlier file per role" in the error tables: the R-arm drot outliers, the 1.44 m finger bases, the Bip02 residues). The plugin build id pair in 0x0107 corroborates ((15780518, 12779458) on every corpus file). On the queen, the half-staleness is even per-record — its ×24-scaled finger bases are current only in the LEFT half while its toe bases are current only in the right — so no selection rule is derivable from one file, and the exporter keeps the legacy right-half read (the flag is parsed and documented as the gate point, SBipedRig::FigureVersion). Reference-data caveat (Kaetemi): the kitin queen was likely fixed up manually at some point after the reference exports (which would be exactly what rewrote its figure records in the fresh format and set 0x0115 = 0), and some kitin-queen assets crash 3ds Max, so its reference .skel may additionally come from a broken export. Either way: every queen mismatch is the reference-era class (max file authoritative), not decode signal. When fresh-format (0x0115 == 0) files matter for real (newly authored content through the headless pipeline), re-derive the fresh conventions from the differential dataset plus purpose-made asymmetric variants rather than from the queen.
Remaining biped work:
_big/_small, humanoid re-proportioning), the ~30 moved-footsteps rigs (ground not stored), figure-scaled tail bases (kitin_queen ×24, kamique), and the ~0.5–5 mm chain-position residues (tail.0/spine links/pony1.0/neck.0/finger bases — suspected per-limb figure scale factor; the dataset's sc_* scale variants localize candidate slots: leg scale at 0x000f[31]-region + 0x01f9, per-chain length tables at 0x01f4..0x01fd).prs:name markers not COM-parented (~28).For the animation-export phase (future): the SDK reference documents the biped key layouts (IBipedKey TCB params; IBipedVertKey.z/IBipedHorzKey.x,y/IBipedTurnKey.q; IBipedBodyKey IK pivots; footstep keys with Matrix3 + duration) and the non-uniform-scale export protocol (RemoveNonUniformScale — biped node TMs carry non-uniform scale pre-2.1, object-offset post-2.1; removal corrupts Physique export so it must only be active for motion export). The Physique export interface (IPhyRigidVertex/IPhyBlendedRigidVertex, offset vectors in bone-local space) is the reference for the .shape skinning decode.
Corpus status after the full session (2026-07-05): T1/T2 169/169 + 9/9; biped size-match 169/169, dpos 4386+4053/11120 exact+close (err 0.0401), drot 8368+413/11120 (err 0.1037), byte-match 69.0%; non-biped bit-exact on all dpos and drot (222/222 each), byte-match 90.9% (rest is InvBindPos float noise). The pipeline_max_skel_corpus ctest gates T1/T2 and reports T3 over the exact SkelSourceDirectories workspace listing.
The keyframe animation controllers are typed scene classes now (builtin/control_keyframer.{h,cpp}, registered in CBuiltin::registerClasses): CControlPosLinear (0x2002), CControlRotLinear (0x2003), CControlScaleLinear (0x2004), CControlFloatLinear (0x2001, added 2026-07-07), CControlFloatBezier (0x2007), CControlPosBezier (0x2008), CControlScaleBezier (0x2010), CControlPosTCB (0x442312), CControlRotTCB (0x442313), CControlScaleTCB (0x442315, added 2026-07-06). A corpus-wide survey over all 4523 anim sources (2026-07-06) confirmed the transform/morph controllers cover every class appearing in the transform/morph roles (PRS refs 0/1/2, LookAt refs 1/2/3, morph-factor pblock ref 0) — no Linear/TCB Float, Bezier Rotation/Point3 or list controllers carry keys there. Linear Float (0x2001) does appear in a role that survey didn't cover: the animated texmap UV coordinate (StdUVGen offset/tiling/angle) behind texture-matrix animation (§10k) — added 2026-07-07 and gated (full-corpus T2 8632/8632; 12-byte {time,flags,val} key off chunk 0x2511). A shared base (CControlKeyFramerBase) claims the controller's known chunks head-first in file order and stops at the first unrecognized id (so unknowns stay orphaned pass-through); all claimed chunks are re-emitted verbatim in original order, with the key table, default value and range additionally exposed through typed read accessors. Raw bytes stay authoritative — no authoring direction — so T2 byte-identity holds by construction. Gate status at landing: skel corpus T1/T2 169/169 + 9/9 unchanged (T3 numbers bit-identical to the pre-change baseline), all 75 anim-source targets pass T1+T2, 800-file random sample of the wide corpus clean.
Common chunks on every keyframe controller, in file order: default value (0x2501 float / 0x2503 pos CVector / 0x2504 rot CQuat — stored in the INVERSE convention, see §10 "PRS-path defects" / 0x2505 scale CVector+CQuat 28B), 0x2500 (8B, unknown, zeros), 0x3002 (int, usually 0), 0x3003 = time range (Max Interval, 2×sint32 ticks; sentinel 0x80000000 pairs = NEVER/FOREVER), 0x2532/0x2533/0x2534 (containers, unknown), key table (per-class, below), 0x3005 (int, 390 in the corpus).
Key-table layouts (little-endian dwords; sizes verified to divide every instance across the 75 anim-source files and the corpus samples; ticks = 1/4800 s, 160/frame):
| Controller | Key chunk | Bytes/key | Layout |
|---|---|---|---|
| Linear Float | 0x2511 | 12 | time, flags, val (added 2026-07-07 — the animated-texmap-UV role, §10k) |
| Linear Position | 0x2513 | 20 | time, flags, val[3] |
| Linear Rotation | 0x2514 | 24 | time, flags, ABSOLUTE quat x,y,z,w (NeL export negates w) |
| Linear Scale | 0x2515 | 36 | time, flags, s[3], q[4] (Max ScaleValue) |
| Bezier Float | 0x2525 | 28 | time, flags, val, intan, outtan, cache[2] |
| Bezier Position | 0x2526 | 80 | time, flags, val[3], intan[3], outtan[3], cache[9] (in/out lengths ⅓ defaults, −1 sentinels) |
| Bezier Scale | 0x2528 | 148 | time, flags, s[3], q[4], then four 7-float blocks at stride 7 — {inTan, outTan, inLen, outLen}, each vec3 + 3 zeros + constant 1.0 tail: inTan [7..9], outTan [14..16], inLen [21..23] (−1 sentinels = default ⅓), outLen [28..30] (resolved 2026-07-09 against the fauna direct refs — the first corpus keys with nonzero tangents; the earlier provisional outTan at [10..12] read zeros, see §10m-ter) |
| TCB Position | 0x2521 | 64 | time, flags, val[3], tens, cont, bias, easeIn, easeOut, cache[6] |
| TCB Rotation | 0x2522 | 92 | time, flags, cumulative ABSOLUTE quat[4], tens/cont/bias/easeIn/easeOut, RELATIVE angle-axis (axis[3] world-space + angle — what Max GetKey returns and the NeL exporter consumes, angle negated), cache[8] |
| TCB Scale | 0x2523 | 112 | time, flags, s[3], q[4] (Max ScaleValue), tens/cont/bias/easeIn/easeOut, cache[14] — decoded 2026-07-06 off the weapon-box scale tracks (fy_hof_a_stun_end Ma_Epee2M et al), 1711 keyed instances corpus-wide |
Bezier key flags: tangent types at bits 7..9 (in) / 10..12 (out); out-type 2 = step (CKeyBezier.Step). Decode provenance: located by matching the stored floats bit-for-bit against direct pre-optimizer reference .anim exports (~/characters/anim_export/ge_mission_eolienne_tr_idle.anim for Bezier pos + TCB rot including the −0.0 tangent signs, ge_mission_borne_teleport_kami_idle.anim for Linear rot). Open: the controller ORT (out-of-range type → NeL _LoopMode) storage bit is not yet located — no anim in the non-biped target corpus has loop mode set, so nothing validates a decode yet (candidates: 0x2500, 0x3002).
pipeline_max_export_anim (2026-07-06, same session). nel/tools/3d/pipeline_max_export_anim/main.cpp replicates NelExportAnimation (build_gamedata processes/anim, scene=false) for non-biped rigs, building real NL3D CAnimation/CTrackKeyFramer* objects (links NeL::3d, registerSerial3d) so the output format is exact by construction. Key facts established:
$Bip01 plus nodes with NEL3D_APPDATA_EXPORT_NODE_ANIMATION == "1". The database convention for non-biped animation rigs is a plain Box literally named "Bip01" as the animated root (every one of the 71 non-biped anim sources) — the AppData flags in the current database are all "0", so $Bip01 is the only selection that reproduces the references. Each selected node exports its own tracks under bare names (scale/rotquat/pos — that insertion order), descendants under flat <nodeName>.<value> names (recursion passes the original prefix down, not nested); first-wins on name collisions; tracks only for supported keyframer sub-controllers with ≥ 1 key on the PRS transform (0x2005) refs 0/1/2.plugin_max/nel_mesh_lib/export_anim.cpp): time = ticks/4800; Linear rot w negated; TCB rot = relative angle-axis with angle negated, axis renormalized with a double-precision norm (the stored axis is a cache accurate to 1-2 ULP; Max renormalizes on GetKey — surfaced by de/la_sky_dome); Bezier Point3 tangents ×4800, Bezier scale tangents unscaled (reference-exporter inconsistency, reproduced); scale values via the Max Inverse(srtm)*stm*srtm diagonal, which in column convention is srtm·stm·srtm⁻¹ (validated to ~1e-6 against animated non-identity-q scale keys; the other order is off ~3e-4); range from 0x3003 with NEVER/FOREVER falling back to first/last key.pr_mo_phytopsy_attack); Max rederives from the absolute quat — the evaluated result is the stored absolute quat's conjugate bit-exactly, reproduced through axis=xyz/‖xyz‖ (double), angle=2·asin(‖xyz‖) to within 1 ULP. Only key-0 derivation is known (= relative to identity); a flagged non-first key would warn (none exist in the corpus).core4_data/sky, eolienne + borne_teleport + 4 karavan kites vs ~/characters/anim_export); 61/61 fauna (plante_carnivore) via in-tree anim_builder + fauna cfg vs core4_data/fauna_animations: 1 byte-identical, 60 within the optimizer-threshold tolerance (worst reconstructed delta 0.0017; the reference builder ran on 2004-era x87 codegen, so borderline key-drop decisions flip freely — whole drop patterns diverge on slow tracks while the reconstructed animation stays equivalent). The 4 ge_mission_kite_kamique_* files carry real bipeds and are deferred to the biped anim phase (exporter refuses with exit 2).pipeline_max_corpus_test/anim_corpus.py — T1/T2 over the anim corpus, T3 export with the two-tier reference comparison (direct = byte-identity required; optimized = anim_builder pass + reconstructed-animation comparison with double-cover-aware quat deltas, tolerances derived from the optimizer's own thresholds: quat 0.002, vector 5e-4). Wired into ctest as pipeline_max_anim_corpus (--all --gate-t3 --nonbiped-only, self-skips without the private checkouts). Full-corpus T1/T2 sweep over all 4524 anim sources: clean (one-time validation after the typed controller classes landed).Remaining anim work: biped anim export (§10c), NoteTrack/SSS, morph, camera, particle-system tracks (§10d — all landed 2026-07-06); still open: the ORT/loop bit (no corpus anim sets loop mode, nothing to validate a decode against) and the CS IK in-between solve (§10c). NEL3D_APPDATA_EXPORT_ANIMATION_PREFIXE_NAME/instance-name prefixes remain implemented-untested (no corpus file sets the appdata; the "trigger_right." prefixes on the PSTrigger files come through this path and match, which is the first real signal it works).
pipeline_max_export_anim handles biped rigs since this session: the figure rig is reconstructed through the new shared pipeline_max_rig library (extracted verbatim from pipeline_max_export_skel/main.cpp, gated byte-identical on the skel corpus; renamed pipeline_max_export_common 2026-07-07 once it also picked up the Max Matrix3 math and the DB-path resolver — see the ligo-ig session note in §10g-bis), the animation keytracks are decoded off the Biped (0x9155) system object, and every biped node is oversampled once per frame across the union key range into CTrackKeyFramerLinearQuat/LinearVector tracks — replicating CExportNel::addBipedNodeTracks + overSampleBipedAnimation (NL3D_BIPED_OVERSAMPLING=30 at 30 fps ≡ one sample per frame, step 160 ticks, last sample clamped to the range end). Pos tracks are emitted for the COM and the biped.getNode(#larm/#rarm/#spine/#tail) first links (clavicles, spine base, tail base) = the reference's mustExportBipedBonePos set; rot tracks for every biped node; no scale tracks. Track names are flat (parentName + nodeName + ".", bare for the selected root), first-wins on collisions (the L-side nub dummies share their base bones' names and lose). Non-biped children inside the biped subtree (weapon boxes, Footsteps, markers) ride the existing PRS keyframer path.
Range and sampling rules (corrected 2026-07-06, anim-completion session — these closed the 26 key-count mismatches): the reference's BipedRangeMin/Max is the union of the BIPSLAVE nodes' IKeyControl key spans ONLY — the COM (BIPBODY) controller contributes NOTHING (its h/v/t keys are invisible to that enumeration: fy_hof_co_fus_tir's turn/vertical tracks run to tick 13440 while the reference range stops at the limbs' 4800; same shape on the arms-only first-person rigs). Keytracks without a corresponding scene bone still don't count, and pony2 does (fy_hom_cur_heal_hp_end: pony2 to 6400 vs everything else 4800). The oversampling loop places samples at min + k*160 while <= max with NO final clamped sample (the reference loop's inner std::min clamp is dead code — fy_hof_co_a1md_attente2's range ends at tick 5638 and the reference's last sample sits at 5600). Every biped track's unlockRange is the global sampled span — the reference's per-controller GetTimeRange is FOREVER/NEVER on biped controllers so every track falls back to its first/last sampled key; the per-limb spans previously used here were wrong (range == key span on every direct reference). Track names are bare only when the selected node's parent is the scene root (CNelExport::exportAnim: GetParentNode() == GetRootNode()); fy_hof_monture_aquatique_demitour_gauche_attack's Bip01 hangs under a stick_1 node and gets "Bip01."-prefixed tracks.
Keytrack storage — chunk pairs (data, time) on the 0x9155 object, decoded against direct-reference .anim exports (~/characters/anim_export) via a conjugation solver (find constant quats A,B with target = A·g(s)·B per candidate reference frame; crisp constants = decode, non-crisp = wrong frame):
| Chunks | Track | Data layout (per key) |
|---|---|---|
| 0x012c/d | horizontal | hdr 1; rec 10: COM position Y-up (x, y↑, z↑) at [0..2] (drives world x,y) |
| 0x012e/f | turn | hdr 3; rec 13: [1] signed angle, quat Y-up Max-conv at [4..7] |
| 0x0130/1 | vertical | TWO banks (count + count×13 each); rec: z at [2] (drives world z) |
| 0x0132/3 | pelvis | hdr 3; rec 7: quat [0..3] |
| 0x0134/5, 0x0136/7 | R arm, L arm | hdr 4; rec 110 (right track first, like the pose-record halves) |
| 0x0138/9, 0x013a/b | R leg, L leg | hdr 4; rec 110 (+2 floats head-shift per leg link beyond 3, same as 0x0069) |
| 0x013c/d | spine | hdr 4; rec = 1+n: [0]=n (int), n angle floats (3 per link) |
| 0x013e/f | head | hdr 3; rec 12: head quat [0..3], zeros, [7]=count, neck angles [8..] |
| 0x0142/3 | tail | like spine; the TIME recs are 26 dwords (per-link TCB sets), times still at rec[0], track TCB at rec[7..9] |
| 0x0147/8 | pony1 | like spine (2-link) |
| 0x0149/a | pony2 | like pony1 (maps to BID_PONY2 = 18; decoded 2026-07-06 — extends the sampled range on 26 character files and drives the Ponytail2 chain) |
Time chunk: hdr 7 dwords (count, ?, ?, ?, ?, trackType≈nLinks, ?), then count × 10 dwords (time_ticks, index, p0..p4, tens, cont, bias) — TCB in Max UI units (25 = default → internal (v−25)/25). Limb record fields: [0] hinge angle (elbow/knee; local = Rz(a−π); horse-ankle at [1], Rz(a)), [1]=0.3 ballistic const, [2..5] upper quat, arms [9]/[10] clavicle (Δa, Δb) added to the figure arm-struct values (rule Rx(−a)·Rz(−ε)·B(b) off the last spine link, R = LR mirror), [11] pivot int, [12] IK blend (float 0/1), [14..16]+1 end-effector pos body(COM-rel Y-up), [18..20]+1 end-effector pos WORLD Y-up (the ankle/wrist — exact vs ground truth), [22..24] unit pole vector (Y-up mid-bone pole ⊥ reach — decoded §10s-six; was "unidentified"), [28..31] foot/hand quat, [46+10k..49+10k] finger/toe base delta quat + [54+10k],[55+10k] absolute bend angles (Rz). The [50+10k..53+10k] and [59+10k]-family windows are caches — they do NOT drive the pose (same stored local across files with different cache values proved it).
Conversions (all corpus-validated to float noise at key times; standard-convention quats, C = Rx(+90°) Y-up basis):
C·conj(turn)·C⁻¹; COM pos = (h.x, −h.z↑, vertical.z).COM(t)·Rx(π)·conj(s)·(−.5,−.5,.5,−.5); spine base = COM(t)·(.5,.5,.5,−.5)-class figure attach·R(a₀(t)) — attach folded as C_fig = figComRot⁻¹·figSpineWorld·R(a₀_fig)⁻¹; spine/tail/pony/neck links: local = C_fig·R(aₖ(t)), R(a) = Rx(a3)·Rz(−a1)·Ry(a2) (same as figure); tail base COM-relative like spine; neck angles ride in the head track; head world = COM(t)·C·conj(s)·(−√½,−√½,0,0).COM(t)·(.5,.5,−.5,.5)·conj(s)·Rx(π) (L: stored is LR-mirrored); thigh = COM(t)·(.5,.5,.5,−.5)·conj(s)·Ry(π) (R mirrored — note arms are right-natural, legs left-natural); foot = COM(t)·C·conj(s) both sides direct; hand local = conj(s)·Rx(∓π/2) off the forearm (R: mirror the stored, Rx(+π/2); NO ε in the anim keys unlike the figure default).M_own-half·conj(s), R side mirror-wrapped (the anim deltas reference their OWN half's arm-record matrices — unlike the figure decode's right-half-for-both; residual ~3e-4 ε-class). Toe base = M_right-half·conj(s) both sides, L mirror-wrapped (the L half's toe matrices use another basis — same era caveat as the figure). Bends are absolute Rz(angle), angles unwrapped mod 2π against the previous key (stored values wrap: 0 → 6.22 → 0 across keys).Interpolation — per-channel TCB in STORED space, then per-frame conversion with the time-varying frames. Scalar/vector: Max TCB (the NeL track_tcb.h formulas) except the boundary tangents, where the biped uses the plain difference: tanFrom(first) = (1−t)(v₁−v₀), tanTo(last) = (1−t)(vlast−vprev) (solved from reference in-betweens; NeL/Max's 3-point boundary formula is measurably wrong here). Quats: squad on the raw stored key quats (chained makeClosest, lnDif/exp A-B control points, same factor math), converted after evaluation — validated exact (1e-7) against reference in-betweens on FK intervals, including the time-varying COM composition.
IK — the open work. Keys with [12] = 1 mark IK intervals (planted feet/pinned hands). At key times the stored channels are the SOLVED pose (exact); between keys Character Studio re-solves: the ball-of-foot stays world-planted through pivot rolls (verified: toe-attach point identical across a roll's frames), the ankle path is NOT the world-TCB of the stored ankle positions (0.005 residual), the foot rotation is NOT a squad of the stored channel (≈0.03), the knee angle is NOT its channel's TCB, and α = 1→0 intervals evaluate pure-FK while 0→1 and 1→1 intervals don't. A minimal-rotation-from-key model following the ankle (quat_between on ball→ankle vectors) reduces the foot error ~4× but nothing closes it; the exporter currently ships pure channel-FK for the in-betweens (an experimental 2-bone align+law-of-cosines solve exists behind PMB_BIPED_IK=1 but corrupts at-key exactness through position-model noise and is off by default). Consequence: biped T3 vs direct references is structural + bounded-error, not byte-identical — per-file worst key delta median ≈0.08 rad-class on the character corpus, concentrated 100% in IK-interval in-between frames (everything else is float-noise exact). Next step when this matters: a Max 9 differential ANIM dataset (the biped_dataset_gen.ms channel) with controlled single-effect IK cases to pin the in-between solver exactly.
Stale-cache TCB rot keys, negative-w case (found via box_arme_gauche in ca_hof_mort): when the flagged (0x10) absolute quat has w < 0, Max normalizes to positive w before rederiving — axis = −xyz/‖·‖, angle = 2·acos(−w) (the w ≥ 0 path keeps the previously validated 2·asin(‖xyz‖) form).
Stale-cache TCB rot keys, third data point (2026-07-06 — representation-only, era-drifted, don't re-litigate): the fy_hof/fy_hom_first_* camera-rig dummies (Dummy06/Dummy15, 10 keys over 5 rigs) carry stale near-identity keys where the reference's rederived AXIS is the conjugate of ours ((axis, angle) vs (−axis, −angle) — the identical rotation) and the angle differs by exactly 1 ULP; fy_hof*_marche's Ma_Wea_Epee1M stale keys (w < 0, ‖q‖ drifted past 1) differ era-class too. Both classes prove the CURRENT stored quats are not the bits the reference exporter read (the reference's exact-2^-23 angle needs an exact-2^-24 component; the stored one is 1 ULP off), so the axis-sign question for the w ≥ 0 path is corpus-undecidable and the validated formula stays. The harness's TCBQuat comparison is double-cover aware and treats rotations with |angle| < 1e-5 as axis-degenerate (identity class) — these keys compare on angle and TCB params only.
Validation — anim_corpus.py now runs T3 over biped files too: direct refs (characters, kites) compare at float level (track sets + key counts must match — gated; per-key worst delta reported against --biped-tol, informational), fauna/characters optimized refs via anim_builder + the reconstructed-animation tolerance. The pipeline_max_anim_corpus ctest gates structural correctness on bipeds and byte-identity/optimizer-tolerance on non-bipeds (the --nonbiped-only restriction is gone).
Full-corpus sweep, 2026-07-06 (4524 anim sources: 4452 biped + 71 non-biped + 1 stub): non-biped unchanged green (10/10 direct byte-identical, 61 optimized: 1 identical + 60 within optimizer tolerance). Biped: 3064/3173 direct-ref files structurally exact (same track sets, key counts and ranges; per-file worst key delta median 0.147 — the error is concentrated in IK-interval in-between frames, everything else float-noise); the 109 structural fails decompose into feature gaps, all identified: morph MorphFactor tracks (emo anims), camera pos/roll/scale + PSTrigger (first-person/cutscene files), spawn_script SSS tracks (craft anims), and range/key-count mismatches where the reference's per-node key enumeration sees keys outside the 0x9155 keytracks (suspected Biped SubAnim float keys) or ignores keytracks selectively. Fauna biped optimized refs: 1/1279 within the strict optimizer tolerance — the IK approximation exceeds the 0.002 quat threshold broadly, so this bucket stays informational until the in-between solver is exact. Two fixes landed after the sweep baseline (numbers above predate them): the clavicle attach position now follows the last spine link during animation, and the sampled range unions only the keytracks whose bones exist in the scene (fixes arms-only first-person rigs).
The remaining structural gaps of the anim exporter (the 109 direct-ref track-set/key-count fails of the §10c sweep, decomposed: 36 spawn_script + 30 weapon-scale + 26 key-count + 7 morph + 6 camera + 3 PSTrigger + 1 root-flag) are all closed. The storage decodes, each corpus-validated:
CAnimatable has always preserved verbatim (now exposed read-only via CAnimatable::noteTracks(); the member is also initialized in the constructor now — it previously stayed uninitialized until parse): 0x0130 = note-track count (uint32, always 1 in the corpus; key-to-track assignment is unmarked in storage so >1 warns), then per note key 0x0100 = time ticks + 0x0110 = key flags (4 observed) + 0x0120 = UTF-16 note string.NEL3D_APPDATA_EXPORT_SSS_TRACK contribute their note-track scripts (lspawn/wspawn/lkill/wkill <shape> lines); the CSSSBuild port compiles them into the spawn_script ConstString state-track plus the CAnimation::addSSSShape set exactly like the reference (shape lowercased + .shape appended, one state key per distinct note time, empty key at 0 when none lands there). The harness now parses and compares the animation header's SSS shape set and min-end-time as part of the direct-ref verdict.<parentName><targetNodeName>MorphFactor, channels with a target node and ≥1 key only. The emo files' references are now byte-identical (the whole file carries nothing but morph tracks).roll) and the target node's position controller (target) export only for camera nodes (object superclass 0x20), replicating isCamera.Deliberately NOT implemented (zero corpus signal, rule §12.2): camera FOV (addObjTracks — the pblock controllers exist but never carry keys), material/texture tracks (no NEL3D_APPDATA_EXPORT_ANIMATED_MATERIALS set anywhere), light tracks (no LightmapController reference tracks), PSParam0-3 (controller-backed instances never keyed), and biped COMs nested under selected non-biped nodes (the reference's addBoneTracks re-enters the biped path there; no corpus file has that shape).
Corpus sweep after this round (full 4524 anim sources, --all --gate-t3 green, 2026-07-06): T1/T2 4452/4452 biped + 71/71 non-biped (roundtrip coherency including the new typed TCB Scale class over the whole corpus); T3 direct-ref structural fails 109 → 0 over the 3173 biped direct refs (7 byte-identical — the emo files, which carry nothing but morph tracks — + 3166 structural; worst-key-delta median 0.1356, concentrated in the IK-interval in-between class, 2150 files over the 0.25 informational tolerance); non-biped unchanged green (10/10 direct byte-identical, 1+60/61 fauna within optimizer tolerance); biped optimized refs 11/1279 within the strict tolerance (informational until the IK solve is exact). Newly identified informational outlier class: ship/cutscene rigs with a keyless vertical channel (ship_tank_karavan_mort_idle, COM z off by a constant ~4.7 m): the reference's COM height doesn't appear as a literal stored value anywhere checked in the .max so far — candidate mechanisms (footstep-derived height, a live dynamics/balance solve) are unconfirmed at this point; see §10l's correction and Round 3 below for what was actually ruled in/out. Expect the Max 9 differential anim dataset to help pin it down.
The Max 9 differential ANIM dataset run happened: ~/biped_anim_dataset/ — 36 single-effect cases (FK per joint, TCB parameter sweeps, IK plant/roll/ankle-tension/overextension/blend-transition, dynamics/ballistics, range probes) with per-frame AND quarter-frame biped.getTransform world-transform GT plus per-key property dumps (generator: biped_anim_dataset_gen.ms). Compare harness: pipeline_max_export_anim --dump-samples <out> [--dump-max-frame <f>] evaluates the biped at quarter-frame steps and dumps manifest-format SAMPLE lines. Baseline before fixes: most cases already at the ε-floor (the §10e fresh-format figure residues — the dataset rigs are fresh Max 9 bipeds, so that session's decode work is exactly what made them evaluate cleanly); the real failures and their resolutions:
Dataset status after the round: all 36 cases at the ε-floor except a_ik_blendtrans (3 cm, blend-interval solve) and the two ease-shape cases (≤1.8 cm, approximate warp). Corpus (T3 --gate-t3 green, direct refs still 7/7 + 10/10 byte-identical, err=0): biped worst-delta median 0.1356 → 0.1215, over-tolerance files 886 → 833, optimizer-eps 11 → 15. anim_corpus.py also gained -j/--jobs (default cores−2; submission-order aggregation keeps output byte-identical to a serial run — full sweep 60 min → 7m22s; don't rebuild binaries mid-sweep, a relink race fails subprocesses spuriously).
Round-2 dataset + legacy GT dump (2026-07-06, same day). Both Max runs landed: ~/biped_anim_dataset2 (20 cases, 0 SKIPs) and ~/skel_gt (READ-ONLY dump of all 178 original corpus skeletons, 0 load errors — consumer: skelgt_corpus.py). Findings:
.rotationpart and the 2004 exporter's decompose — not decode signal. This also re-classifies the old "~28 prs:name markers" and "13 L finger bases" open items as likely mirror-authored (verify per case against the reference, not the GT)._ref_scale twin rigs: every _big/_small/_slim/_selle variant file carries a full duplicate rig suffixed _ref_scale (the artists' re-proportioning template, 5205 nodes corpus-wide) — never exported; the harness excludes them from missing-bone accounting.b_dynv_* cases (deleted vertical keys × horizontal/turn/balance/gravAccel) sit at the ε-floor with the vertical at figure height — so the ship_tank 4.7 m class must be FOOTSTEP-driven (footstep mode isn't reliably scriptable; the round-2 script documents a manual-footstep fallback case).b_fk_pelvis, round 1 had no such case): unkeyed thigh ROTATION holds COM-anchored even under pelvis keys, while the thigh POSITION follows the rotated pelvis attach — the world-hold and the attach anchor split. Fixed (was 0.41 m on the feet).b_fk_toebend, toeLinks:3 rig); finger bends stay TCB (b_fk_fingerbend at floor). Multi-link neck at floor.b_ikb_static — static COM, pure blend-in, 4.3 cm — and b_ikb_out 5.3 cm; the in-ramp b_ikb_long and the arm ramp sit at floor). Legacy GT status vs our reconstruction: pos 8112+1879/10475 exact+close, rot 4950+5191 (the close tier = the ε and convention classes above).Round 3 — footstep mode (2026-07-06, same day). ~/biped_anim_dataset3 (freeform-author → biped.convertToFootSteps, all conversions APPLIED). Findings: conversion introduces NO new chunk types — it re-keys the STANDARD tracks (vertical 0x0130 gains generated keys, legs 0x0138/0x013a gain a key per plant/lift event) plus 24 bytes on 0x0102 (the footstep block / mode flag — the footsteps node itself reports 0 keys via IKeyControl). c_fs_still sits at the ε-floor. Decisively: c_fs_vertdel (vertical keys DELETED in footstep mode) evaluates IDENTICALLY to the keyed twin — footstep mode derives the COM vertical from the footsteps, ignoring the vertical track entirely. In these grounded test rigs the footstep-derived height equals the figure height (so the cases can't separate the two), but that is exactly the ship_tank signature: a rig whose FIGURE floats (ship COM ~7.7 m, the moved-footsteps class §10) evaluates at the footstep-derived height instead — a constant offset, matching the observed constant 4.7 m. Offline resolution (ship-family regression over all 25 anims): every ship anim except THREE has refCOM == our export; the two stun anims carry their lowered COM in ordinary vertical KEYS (decoded correctly — they were never in the over-tolerance list); ship_tank_karavan_mort_idle is the SINGLE corpus file with a truly keyless vertical track (0x0130 count 0), and its constant 1.3272 is not the footstep-derived height (0x0102 decodes as a sub-anim index table, not footstep data — conversion stores footsteps as re-keyed standard tracks) and not lowest-point grounding as tried here (the death pose's lowest bone would land at 0.73, not 0). A from-scratch full-file byte scan (2026-07-08, every offset, not just 4-aligned, tolerance 1e-4) re-confirms no literal float32 occurrence of the value anywhere in the 602 KB file. Status is "not yet reproduced", not "irreproducible": if Character Studio derives this from a live balance/settling computation rather than a stored field, that computation is presumably deterministic and therefore reverse-engineerable in principle — same category as the IK-blend-transition open item, not a different one. The earlier "Character-Studio dynamics/balance output... genuinely irreproducible" phrasing overstated what the two ruled-out hypotheses actually established and is corrected here. Practically: one file in 4452 with a single, fully-characterized, bounded constant-offset deviation (everything else about the file — x/y position, every rotation track — is correct) is low priority relative to the IK-blend-transition class, which affects far more of the corpus; stays reference-era informational and gate-tolerated, open for whoever next has a reason to chase a single-file curiosity. The walking cases' residual (thigh/calf rot ≤0.17, foot pos ≤0.06, foot rot exact) is the known IK-interval class with dense per-plant keys — same family as the blend-curve fit, now with footstep-mode GT too.
The Max 9 regeneration run from §10 happened: biped_regen_max9.ms (assembled by gen_biped_regen.py from the pre-improvement decode) was executed in Max 9, producing ~/biped_regen/ — 147 fresh-format (0x0115 == 0) .max files plus manifest.txt with per-bone biped.getTransform ground truth (world pos + MAXScript-convention quat, 6 significant digits), no errors. Two caveats govern its use: (1) the script predates later decode improvements, so the only authoritative relation is maxscript ↔ its outputs — Max also re-derives constrained values (pelvis width, clavicle attach, spine lengths) rather than honoring setTransform verbatim, so the regen rigs deliberately do NOT match the originals; (2) the GT is what the fresh files' records produce, which makes it a per-bone decode oracle for the fresh format — something the legacy corpus (byte-compare only) never gave us.
Harness: regen_corpus.py (ctest pipeline_max_regen_corpus, self-skips without ~/biped_regen) runs T1/T2 roundtrip over the regen files plus a bone-by-bone GT compare against our reconstruction, via two new pipeline_max_export_skel modes: --manifest <path> (dump the walked bones' figure-mode world transforms in manifest format, id/link 1-based, quats conjugated to MAXScript convention, per-bone father name) and --dump-rig <path> (raw float dump of every chunk on each 0x9155 system object — the record-analysis workhorse). Gate floors at landing: pos exact (2e-4 rel-aware) ≥ 72%, rot exact (1e-4 quat) ≥ 58%, both exact+close (0.02) ≥ 99.5%, no missing bones.
Fresh-format decode rules established (all gated on FigureVersion == 0; legacy paths bit-untouched — legacy T3 re-verified, actually improved via kitin_queen):
0x000d ([0]=int type, then a variable-length head of 1–2 width params — two on the kami-guide family — with the 4×4 matrix ALWAYS at the END, like the chain records) carries the last-spine-link-end → neck attach offset in its matrix translation (MatPos convention (m13, m12, −m14)). Zero on every legacy file (rule applied unconditionally); up to −0.374 m on kami_guide, −0.226 m on the kitins.a1 − ε (regen stored a1 differs from the legacy twin's by exactly ε on every such base; angle-only chains (neck) do NOT bake it). Companion position shift: every chain element's local position gains [Rz(−ε) − I]·A(own angles)·(own length, 0, 0) — confirmed exactly on tr_mo_c03 (base dx +183 µm dy −49 µm, both predicted) and the kakty/chonari/ryzerb/capryni link steps across bend angles 0..90°, implemented for the spine (chainEpsShift); tail/pony bases keep the plain rule (their residues are sub-mm).[7] (foot height) in fresh rigs (fy_hom −0.011485 exact, ca_spaceship 7.8084 − 0.4040; the legacy toe-attach rule was ~0.5 m off on the deep-ankle monster families). patchFootstepsGround picks the rule by FigureVersion and now also keeps WorldTM/g_msBones coherent with the patch.Regen GT status at landing: T1/T2 147/147 (fresh Max 9 files roundtrip byte-identical with zero parser changes — the chunk layer holds across eras); GT pos 5197+1758/6955 exact+close (avg err 0.34 mm), rot 4247+2708/6955 (avg err 1.6e-4), worst single bone 8 mm — down from pos err 92 mm / rot err 5.0e-2 with 1.41-rad toe classes before the session. Legacy corpus after the same changes: T1/T2 169/169 + 9/9, T3 dpos 4420+4099 (was 4408+4101), drot 11059+3 (was 11058+3), non-biped untouched — the kitin queen (the corpus's one fresh-format file) is what improved.
Open fresh-format residues (documented, sub-threshold): (1) the ε-twist second-order class — finger bases carry a constant ε rotation about a fixed finger-local axis (~(−0.418, 0.013, 0.908), all fingers all rigs), hands a residual 2.3e-4-quat twist (likely the π/2−ε hand twist differing in fresh), the neck attach a ≤5e-4 m residual on bent-spine monsters, and the chain eps-shift second-order terms (≤2.4e-5); (2) ma_hof_big/_big_slim L fingers 8 mm (L-half asymmetry class); (3) files fully-exact 1/147 is expected to rise as (1) closes. The unexplained-in-closed-form part of the chain drift (δ ∈ [0.72, 0.80] mrad across rigs with identical stored a1) is likely the same phenomenon as the legacy corpus's ~0.5–5 mm chain-position residues (§10 open item 1) — the regen GT finally gives per-bone numbers to regress it against.
Next regen iteration: with the improved decode, gen_biped_regen.py can emit a v2 script whose setTransform inputs match the originals far more closely (v1 fed the pre-improvement decode); a v2 Max 9 run would give a cleaner GT set, exercise the two-biped kitin rigs (still skipped), and let the record-level diff against the originals discriminate the remaining open slots. (v2 delivered same day; spot checks then showed grossly mis-sized thighs/calves + too-narrow pelves — biped.setTransform #pos rubber-bands the arm/spine chains but NOT the leg chain or pelvis width. v3 adds figure-mode SCALE forcing before the transform passes: SW scales the pelvis uniformly to the decoded thigh side offset, SL scales each leg link's length (4-link mounts included) by the measured-vs-decoded child-distance ratio.)
pipeline_max_export_swt replicates NelExportSkeletonWeight (processes/swt): walk the scene nodes in container order (= the maxscript's max select all enumeration), skip hidden nodes (node flag chunk 0x0963 bit 0x40 — the swt maxscript unhides categories but never calls its own unhidelayers, and per-node hidden state survives select all), keep nodes whose NEL3D_APPDATA_EXPORT_SWT appdata is non-zero, and emit three CSkeletonWeight entries per node (<name>.rotquat/.pos/.scale, shared weight from NEL3D_APPDATA_EXPORT_SWT_WEIGHT). Gate: pipeline_max_swt_corpus ctest (swt_corpus.py — T1/T2 over the sources + T3 vs ~/characters/swt). The single corpus source (max_top.max) reproduces the reference's 177 entries in order and bit-exact weights; the current .max additionally flags box_arme/box_arme_gauche (reference-era class, max file authoritative — documented as the harness's era allowance).
pipeline_max_export_ig replicates the ig process (processes/ig): the maxscript scans every scene object for the NEL3D_APPDATA_IGNAME appdata, then per distinct ig name selects the geometry, then lights, then helpers carrying that name (three selectmore passes — selection arrays keep selection order, confirmed by the reference instance ordering: dummies always land after all meshes) and calls NelExportInstanceGroup = CExportNel::buildInstanceGroup (plugin_max/nel_mesh_lib/export_scene.cpp) + CInstanceGroup::serial. One <igname>.ig per distinct name. Corpus (the exact 1_export listing): 99 .max under the workspace IgOtherSourceDirectories (all IgLandSourceDirectories are commented out) — indoors (16), village constructions, canopes (12), waters (6), outgame apartments (4), sky domes (4) — producing 40 distinct igs, all with references: ~/core4_data/<continent>_ig + outgame + sky (FINAL client data: ig_static_other → ig_elevation → ig_other → ig_lighter → client, so those compare structurally with lighting fields masked; the raw-intermediate reference set for byte-compare is produced by gen_ig_export.py's Max script → ~/ig_export, same role as ~/characters/anim_export for anim). Facts established, all corpus-validated:
(NEL3D_APPDATA_ACCEL&3)==0), not RPO zones, and isMesh (GeomObject superclass, Target (0x1020,0) excluded, NEL3D_APPDATA_COLLISION excluded) or isDummy (ClassId PartA 0x876234). Per instance: InstanceName (appdata, node-name fallback), Name = getNelObjectName (PS ps_file_name from the PB2 constant record → basename; node appdata 1970; object appdata 1970 → basename; node name), Visible (CAMERA_COLLISION_MESH_GENERATION != 3), DontAddToScene, DontCastShadowForInterior/Exterior appdatas, and the parent-index quirk replicated as-is (the parent search enumerates isMesh-only nodes while instance indices count isMesh||isDummy — a dummy parented to an unexported non-root node would get a skewed index).R:\graphics\... path) + source node name (0x0110). Resolved headless by stripping drive + graphics/database and case-insensitive lookup under the database checkout (--db, auto-deduced), loading the referenced .max (cached) and unwrapping the named node's base object recursively — this is what classifies the xref'd ascenseur meshes/dummies/lights and routes remote object appdata (EvalWorldState semantics).Inverse(srtm)·stm·srtm. localTM = nodeTM·Inverse(parentTM) then decomp_affine (max_math.cpp — faithful float port of the Graphics Gems IV polar decomposition the SDK wraps) → Rot = (q.x,q.y,q.z,-q.w), Scale = f·diag(Inverse(srtm)·stm·srtm) from the stretch parts, the decompMatrix convention. Max Quat::MakeMatrix is the TRANSPOSE of the standard quat matrix (the matrix-level face of the "controllers store the inverse convention" rule).DontCastShadow), bit 0x0400 = receive-shadows (discriminated by the canope class: receives-but-doesn't-cast).Pi·deg/(2·180). Word chunk 0x2562 ≠ 0 = use-attenuation (else radii (10,10)); empty chunk 0x2600 present = ambient-only; affect-diffuse/specular storage was never located (0x2561 was a false lead — outgame lights carry 0 there yet export colored) and every corpus light exports with both enabled, so they're unconditional. Target-spot direction = normalize(targetPos − lightPos) via the LookAt controller's target node (reference 0). AppData 41654685 = animated-light name (exported unless "Sun"/"GlobalLight"/"(Use NelLight Modifier)"), 41654687 = light group; lights sort via the real CPointLightNamedArray::build, whose std::sort tie order is STL-implementation-defined — the reference's MSVC permutation is not reproducible in principle, an accepted byte-identity deviation class (the harness compares lights order-tolerantly, exact 1:1 field match).CCluster::makeVolume per evaluated mesh triangle, sound group/env-fx appdata 84682542/84682543 with the "no sound"/"no fx" sentinel semantics, father-visible/audible bits from accel flags), portal = accel&3 == 1 (edge-stitch loop over the evaluated faces into one ordered poly → CPortal::setPoly; accel&16 = dynamic portal named from INSTANCE_NAME/node name; occlusion appdatas 84682540/84682541), links = accel&32 instances tested vertex-in-volume per cluster; FX instances test the 8 world-transformed corners of the .ps shape's getAABBox (shape resolved by basename through --ps-path, export-era shapes first) with the placeholder mesh as fallback, dummies test nothing (empty mesh, reference behavior).createMeshBuild + convertToWorldCoordinate replication): world verts = m1 · (objectToLocal · v) where objectToLocal = convertMatrix(objectTM·Inverse(nodeTM)) (object offset, node chunks 0x096a/b/c) and m1 = T·R·S from the decompMatrix parts of the node's LOCAL matrix (both through real NeL CMatrix ops — the reference used the same code, so this half is float-exact). Sources: EditableMesh = GeomBuffers tri chunks (0x0914 vertices, 0x0912 faces) raw; Box (0x10,0) topology derived bit-exact from the reference volumes (grid P00(x0,y0) P10 P01 P11 bottom then top; faces bottom (P11,P10,P01)+(P01,P10,P00), top (P00,P10,P11)+(P11,P01,P00), side ring from P00 walking +x with (b1,b2,t1)+(b2,t2,t1) — plane order, windings and cross-product zero SIGNS all match; d values still ±1-2 ULP from the exact first-triangle arithmetic, pending the primitive dataset); Cylinder (0x12,0; params r/h/hsegs/capsegs/sides) bottom cap, sides, top cap (planes within 1-2 ULP); Sphere (0x11,0) pole+rings positions (containment only); scripted plugins (nel_flare extends Sphere, nel_ps extends Box) route to their geometry-delegate reference (superclass 0x10).Box + Edit Mesh OSM Derived stacks with faces deleted/reshaped. Per-modifier per-node data = the wrapper's orphaned 0x2500 containers in reference order (0x2510 = mod-context TM 4×3+flags, 0x2511 = ctx bbox, 0x2512→0x4000 = the mesh delta record: 0x0100/0x0110 input vert/face counts, 0x0140 = moved verts (uint32 count + (uint32 index, Point3 delta)), 0x0170/0x0270 = containers of 0x2700 BitArrays (uint32 bit count + dword-padded LSB-first bits) deleted verts/faces, 0x0210 = created faces (20B stride, not yet applied — vertices suffice for the link test), 0x0300 = subobject level, 0x0400/0x0410 = selections, ignored). Apply: moves → face deletes → vert deletes with reindex. Mirror modifier ((0xef92aa7c,0x511bbe75), mods.dlm): pblock 0 = axis (0-5 = X,Y,Z,XY,YZ,ZX), 1 = copy; gizmo = its own PRS over the mod-context TM; UVW Map (0xf72b1,0) is geometry-neutral.--mask-uninit): instance SunContribution/Light[] are uninitialized heap memory in the 2004-era reference exporter (the CInstance ctor doesn't set them); light array tie order (above); POS_EPS = max(1e-5, 3e-7·|v|) for the ±1-2-ULP x87 noise on large coordinates (sky-dome moon anchors at |pos|≈200 differ by exactly 1 ULP).ig_corpus.py --all --gate-t3, ctest pipeline_max_ig_corpus): corpus now 116 sources (the workspace paths resolve lowercase-dir — stuff/Generique → generique, +17 construction files, per Kaetemi's on-disk convention: lowercase the dirs, filename lowercase-or-verbatim, NLMISC::toLowerAscii) → 55 files exporting 54 distinct igs, T1/T2 116/116 green. Direct tier vs ~/pipeline_export/<group>/<project>/ig_static_other: 53/54 igs field-exact under the whitelist (instances incl. TRS byte-class, lights, cluster counts+volumes, portal polys byte-identical on the editable-mesh cases, links); the single deviation is TR_hall_reu_vitrine_decors' 2 cluster links (its Mirror modifier's default-gizmo plane semantics — the evaluated copy must land two stories down, the plane source isn't pinned yet; --max-direct-diff 1 budget). Processed tier: 39/40 (same case). Open for byte-identity: the Box/Plane/Cylinder first-triangle ULP class and the parametric-primitive vertex order — gen_prim_mesh_dataset.ms (Max-side, same channel as the biped datasets) dumps Box/Plane/Cylinder/Sphere meshes incl. negative-dimension variants and two Mirror-modifier cases to settle all of these plus the mirror plane.Primitive dataset round (2026-07-06, same day). The Max 9 run happened (~/prim_mesh_dataset/manifest.txt); the parametric topology is now GROUND-TRUTH EXACT (buildParametricMesh, validated by prim_check.py / ctest pipeline_max_prim_mesh, 12/12 cases): Box = bottom grid + top grid (ix fastest, x/y ascending) + hs−1 middle perimeter rings walking P00→+x→+y→−x→−y; faces bottom cells (a, a+row, a+row+1)+(a+row+1, a+1, a), top (a, a+1, a+1+row)+(a+1+row, a+row, a), then sides grouped per side (−y, +x, +y, −x), per level bottom-up, per segment: (lo1, lo2, up2)+(up2, up1, lo1); negative height flips every winding (swap first two indices); negative length/width only flip coordinates (the mesh goes inside-out, matIDs shift — replicated literally). Cylinder = bottom center, rings bottom-up at k·2π/sides from +x CCW, top center; bottom cap (c, r[k+1], r[k]), sides (lo[k], up[k+1], up[k])+(lo[k], lo[k+1], up[k+1]), top cap (tc, t[k], t[k+1]). Sphere = top pole, rings top-down with meridians starting at +Y CCW, bottom pole; fans + quad rows (u[k], lo[k], lo[k+1])+(u[k], lo[k+1], u[k+1]). Plane cells (d, a, c)+(b, c, a). Mirror: the default gizmo is the object pivot (identity — NOT the mod-context bbox center), mirrored coord = offset − coord along the flipped axis (pblock 0 = axis, 1 = copy, offset = param 2 when set). With the exact topology the earlier zero-sign plane diffs vanished; the corpus cluster volumes/portal polys are byte-exact except two 1-ULP plane d values corpus-wide (world-vertex transform noise upstream of CPlane::make — the same x87 class as POS_EPS). The one budgeted deviation stands: TR_hall_reu_vitrine_decors (its evaluated geometry per the stored Edit Mesh + identity-gizmo Mirror stays two stories above where the reference's cluster links say it reached; every storage candidate for the plane reads identity/zero) — gen_decors_probe.ms dumps that node's evaluated mesh + per-modifier gizmo from Max to settle it.
Ig content is also exported from the ligo brick .max files (zonematerial-<mat>-<cell>.max / zonespecial-<name>.max / zonetransition-<a>-<b>-<t>.max, the same files pipeline_max_export_zone --ligo already reads) via a second maxscript function, exportInstanceGroupFromZone in processes/ligo/maxscript/nel_ligo_export.ms — distinct from processes/ig/maxscript/ig_export.ms (§10g) even though both ultimately call NelExportInstanceGroup. pipeline_max_export_ig gained a --ligo <outdir> [--cellsize 160] mode reusing its existing node classification/buildInstanceGroup (no new library extracted — see the max_math.cpp precedent this follows: small, self-contained per-tool code, not a shared library, for logic that's cheap to duplicate and unlikely to drift).
exportInstanceGroupFromZone runs with igName="", meaning every distinct ig name found in the file gets exported — structurally the same operation ig_export.ms performs, just two differences: the ligo maxscript's own getIg lowercases the appdata value (ig_export.ms's does not), and output routes to <ecosystem>/ligo_es/igs/ instead of ig_static_other/ig_static_land. Implemented as the same distinct-name scan as the standalone path, with the per-node compare ALSO lowercased (exportIgForName(..., lowercaseCompare=true, ...)) — the corpus mixes exact-lowercase and mixed-case ig-name tags on nodes that belong to the SAME group (e.g. converted-164_eg / converted-164_EG both tag light + geometry nodes of one street-lamp ig), so comparing the lowercased scan result against a NOT-relowered per-node value silently dropped every mixed-case-tagged node — this was the first bug caught by the corpus (see below).exportInstanceGroupFromZone is called 9 times per file with igName = lowercase(zoneBaseName) (<a>-<b>-<c>-<zone>, 0-based, the same zoneBaseName pipeline_max_export_zone already builds for .zone/.ligozone) and transitionZone = zone; only nodes whose ig name matches that EXACT slot name are selected (artists tag each of the 9 template placements with a distinct name baked to its slot), and each selected node's transform is overwritten via buildTransitionMatrixObj before the normal instance-group build — replicating the maxscript literally setting node.transform on the live scene nodes it just selected. The gate for running this at all (getTransitionZoneCoordinates, ok/findOne in the maxscript) is replicated as classifyTransitionGrid: every non-frozen RklPatch node's bbox center (from its OWN un-evaluated CRklPatchObject::decodePatch verts — no NeL Edit/Paint modifier-stack evaluation needed, since 160m-cell classification only needs to land in the right bucket, not be vertex-exact like the real .zone export) must fall in a valid TransitionIds[y][x] cell.buildTransitionMatrixObj (distinct from zone's already-shipped buildTransitionMatrix, §10h, despite the similar shape — one repositions the RklPatch's own vertices by zeroing pos/mirroring-rotating about the origin/translating to the grid slot plus the original offset; this one repositions a live node's WORLD transform by recentering the pivot at the cell center before the mirror/rotate — transMatrix(gridPos) → translate(−cellSize/2,−cellSize/2,0) → [mirror] → [rotateZ] → translate(+cellSize/2,+cellSize/2,0) — then composing as mt · copyMt rather than overwriting mt, matching the maxscript returning (mt * copyMt) instead of assigning into mt). The MaxScript source calls scale copyMt [-1,1,1] true (a 3rd positional argument absent from buildTransitionMatrix's 2-arg scale copyMt [-1,1,1]); its effect could not be pinned from the SDK docs alone, so it was implemented as the same "negate column 0 of every row" op §10h's version uses (the only plausible reading for a bare Matrix3, no live node/pivot for a 3rd arg to address) and left for the corpus to confirm or refute. Corpus-validated: all 9 grid slots are exercised across the 4-ecosystem corpus (zone counts 0,1,2,3,4,5,6,7,8 all present, including zone 4 — the one TransitionScale[4]=true mirror case — and every nonzero-rotation slot), zero unexplained diffs on any of them (44 zonetransition igs produced corpus-wide, all byte-identical or uninit-bytes-only against the reference) — the derivation, "true" flag included, stands as implemented.CReferenceMaker-style case handling aside, the FIRST bug the corpus surfaced was the lowercase-comparison mismatch above (68 Omni streetlights on converted-164_eg silently dropped, shrinking a 5.6KB reference ig to a 250-byte one with only the 2 already-lowercase-tagged instances) — found by literally diffing --info dumps of the two files down to the missing point-light block, fixed by threading lowercaseCompare=true through the zonematerial/zonespecial branch. Second: the harness initially didn't pass --ps-path, so every clusterize FX-instance test fell back to the placeholder-mesh bbox instead of the real .ps shape's — fixed by adding ~/pipeline_export/common/sfx/ps to the harness's search path (same convention ig_corpus.py already uses), which alone closed a third of the remaining diffs (e.g. forge.ig's Nel-particle-system cluster-link count).nb01..nb05, jungle foret-18..21_village_a/b/c/d, a couple of ilot_butte igs) — same instance COUNT and NAME SET as the reference, different SEQUENCE (confirmed by diffing --info listings side by side: e.g. two FY_Acc_Reverb_A_* entries land two slots later in ours than in the reference, with everything else shifted around them) — our selection walks the Scene stream's storage order, the reference walks Max's live per-category node array, and for most of the corpus these coincide but not for these files; no xref/path-resolution warnings on any of them, so it isn't an unresolved-reference artifact. Same open-issue class as the shape exporter's "candide" duplicate-node-name ordering note (§10i handoff). (2) cluster-membership edge cases on a handful of small decorative meshes (e.g. fy_mairie's _puit_carre2/3 wells: reference says 1 cluster, ours says 0) — the vertex-in-volume clusterize link test disagreeing on containment for a small number of borderline meshes, not yet root-caused.ligo_ig_corpus.py --all --gate-t3, ctest pipeline_max_ligo_ig_corpus): corpus = every zonematerial-/zonespecial-/zonetransition- file under landscape/ligo/<eco>/max for the 4 ecosystems (the same listing zone_corpus.py drives) — 1201 files, T1/T2 1201/1201 (unchanged — no new typed chunks, this session is pure export logic). T3: 295 files produce ig content (906 have none), yielding 537 distinct (ecosystem, igname) outputs — matches the full ~/pipeline_export/ecosystems/*/ligo_es/igs reference count exactly. 448/537 (83.4%) match: 71 byte-identical + 377 uninit-bytes-only (the same SunContribution/Light[]/light-tie-order whitelist §10g already established) under --mask-uninit; the remaining 89 are the two open classes above (DIFF_BUDGET = 89, gate fails only on regression past that count — tightening it as the open items close is expected, not this session's scope). A handful of "village"/ville_zorai bundle files carry dozens of distinct ig names in one .max and take up to ~70s each (every name re-walks the scene through the existing buildInstanceGroup — legitimate additional work scaling with content, not a regression); the full sweep runs in well under 3 minutes at -j 12.Shared-library consolidation (2026-07-07, same session, prompted by review during the ligo-ig work). Three of pipeline_max_export_ig/_zone/_shape carried byte-identical (and, for _shape, silently drifted-behind) copies of max_math.{h,cpp} (the Max Matrix3/decomp_affine emulation, §10g), and _ig/_shape separately carried byte-identical copies of the "strip drive + graphics/database prefix, resolve case-insensitively under --db" XRef path-resolution logic. Both were folded into the pipeline_max_rig library (extracted 2026-07-06 for the biped skel/anim decode, §10c) — which was renamed pipeline_max_export_common in the process, since it's no longer biped-specific — rather than spinning up yet another single-purpose library. New db_path.h/.cpp (DBPATH:: namespace) generalizes the resolver: DBPATH::addAlias(windowsPrefix, replacementRoot) (new --path-alias <prefix>=<root> CLI flag on _ig/_shape) registers an explicit prefix→root mapping checked (longest match) before the existing graphics/database-stripping fallback, for corpus content authored under a different drive letter or root-folder name than R:\graphics\.../R:\database\... — no such content has been found in the corpus yet, so this is unexercised infrastructure, added because the convention is pervasive (XRef object sources today; texture paths, .ps particle-system references, and rbank-style lookups are the same shape of problem, not yet routed through it). Zone gains its first pipeline_max_export_common link (it didn't use the library before). Full 9-test ctest suite re-verified green after the rename (no behavior change, pure code motion).
Shared-library consolidation round 2 — the Max node-TM / decompMatrix layer (2026-07-07). pipeline_max_export_ig and _shape each carried byte-identical copies of the 3ds-Max scene-graph transform helpers that sit on top of max_math: the GetNodeTM(0) replication (PRS/LookAt controllers evaluated at t=0 through composePRS, accumulated up the parent chain, memoized), getLocalMatrix (nodeTM·Inverse(parentTM)), decompMatrix (decomp_affine → the NeL default-track convention: nelPos=t, nelRot=(q.x,q.y,q.z,-q.w), nelScale=f·diag(Inverse(srtm)·stm·srtm)), convertMatrix (Max row-vector Matrix3 → NeL CMatrix via identity()+setRot(I,J,K)+setPos(P)), the controller value-at-t=0 readers (posValueAt0/rotValueAt0/scaleValueAt0/floatValueAt0, wrapping the library keyframers' *ValueAt0 with a raw-chunk fallback), readObjectOffset (0x096a/b/c) and SNodeTMCache + the CLASSID_PRS_CTRL/CLASSID_LOOKAT_CTRL constants. Folded into a new pipeline_max_export_common/max_scene.{h,cpp} (MAXSCENE:: namespace, sits on max_math). Both tools were retargeted onto it by aliasing their existing symbols to the MAXSCENE:: ones (using-declarations in shape's SCENELIB/MESHBUILD namespaces, file-scope using in ig's main.cpp), so every call site is unchanged and the object code is identical — ig pipeline_max_ig_corpus and shape common/objects T3 re-verified byte-identical (gate exit 0, only the pre-existing whitelisted/budgeted diff classes). convertMatrix was unified to the reference's identity()+setRot+setPos form (numerically identical to shape's former set(m), additionally carrying the correct state bits). Available for the anim exporter and any future consumer.
Skel float-precision — the field-by-field refinement (2026-07-07, VS2008 x87 measured, shipped as walkNodeMax). The non-biped .skel path (pipeline_max_export_skel/biped_rig, the 9 all-PRS fauna files) built InvBindPos + default tracks through NeL CMatrix and read the default Pos/RotQuat/Scale directly from the PRS controller values, whereas the reference CExportNel::buildSkeleton builds them through Max Matrix3 + decomp_affine (decompMatrix(getLocalMatrix) for the defaults, convertMatrix(GetNodeTM).invert() for InvBindPos). The wholesale swap of the non-biped walk onto the max_scene Max-Matrix3 path (to match the reference arithmetic operation-for-operation) was tried first and is a wash-to-worse — not every field wants the same construction. But an env-gated per-field A/B (PMB_SKEL_CAND, since removed) over both the x64/SSE and the VS2008 x87 reference-codegen builds pinned exactly which construction bit-matches the reference for each field, and one field wins cleanly. Non-biped bit-exact counts (of 222 bones), old vs the wholesale swap vs the shipped hybrid:
| field | old (direct/NeL) | full max_scene |
shipped hybrid | source of the shipped value |
|---|---|---|---|---|
DefaultPos |
47 / 47 | 84 / 53 | 84 / 53 | Max quotient decompMatrix(nodeTM·Inverse(parentTM)).t |
DefaultRotQuat |
32 / 37 | 20 / 17 | 32 / 37 | direct PRS controller quat |
DefaultScale |
91 / 91 | 82 / 74 | 91 / 91 | direct PRS controller scale |
InvBindPos bones |
27 / 41 | 18 / 21 | 27 / 41 | NeL CMatrix GetNodeTM accumulation |
(cells are x64 / x87.) The only field the reference computes differently from a direct read is DefaultPos: it is the quotient-matrix translation (nodeTM·Inverse(parentTM)).trans, not the raw controller pos — so taking that one value from max_scene (getLocalMatrix + decompMatrix) while leaving everything else on the direct/NeL path is a strict improvement (DefaultPos bit-exact 47→84 x64 / 47→53 x87, byte-match 90.9→91.6% / 89.4→89.9%, no regression on any other field on either codegen). That hybrid shipped as biped_rig::walkNodeMax (the non-biped default; biped files keep the figure-mode walkNode); the skel corpus gate stays green. Why the other fields resist the Max path (documented so nobody re-swaps them): (1) our Graphics-Gems decomp_affine port is not bit-identical to Max's compiled SDK decomp_affine.obj, and skeletons carry many near-identity local rotations that a direct controller read reproduces bit-exactly but a decompose perturbs by ±1 ULP — so DefaultRotQuat/DefaultScale are better direct; (2) for InvBindPos, neither the NeL-CMatrix nor the Max-Matrix3 accumulation is bit-identical to Max's internally-cached GetNodeTM, and empirically the NeL accumulation already lands closer (41 vs 21 exact under x87), so the "more faithful" Matrix3 accumulation is a step backward there. This still leaves §10i's standing conclusion intact for full bit-exactness: the only route to bit-exact DefaultRotQuat/Scale/InvBindPos is linking Max 2010's actual SDK object code under the VS2008/Wine harness — worth doing only if a byte-reproducible reference build becomes a hard requirement (outputs are already 222/222 epsilon-exact and load identically in-engine; FLOATEQ is the equality tier). The max_scene consolidation (ig/shape dedup, above) stands on its own merits independent of this.
pipeline_max_export_zone replicates the ligo zone export (NeLLigoExportZone, build_gamedata processes/ligo/1_export.py + maxscript/nel_ligo_export.ms) and the direct zone export (ExportRykolZone, processes/zone/1_export.py): .max -> .zone (+ .ligozone XML brick for ligo). Modes: --ligo <outdir> (filename protocol drives everything), --zone <out> (direct, zoneId from the NN_AB node name: (num-1)*256 + (L1-'A')*26 + (L2-'A'+1) - 1), --survey, --dump-tiles, --inspect <patch>; --bank <smallbank> needed only for transition symmetry/rotation (CPatchInfo::transform).
pipeline_max/nelpatch/): CRklPatchObject claims the known chunk ids head-first and re-emits verbatim (zero orphans corpus-wide); rpo_data.{h,cpp} decodes the 0x08FD RPO blob (uint32 rpoVersion=0 + RPatchMesh v1-9; corpus is 100% v9: per-patch NbTilesU/V + tiles (Num, Flags, Noise byte, 3 layers of (reserved u8, Tile u16, Rotate u8)) + colors + edge flags; vertex binds (Binded, Type, Edge, Patch, 5 cache indices, Type2, PrimVert); trailer TileTessLevel/ModeTile/KeepMapping/TransitionType/SelLevel), the flat PatchMesh chunk stream (counts 0x0BD6/0x0BC2/0x0BD1/0x0BEA; verts 0x0BE0, vecs 0x0BCC, edges 0x0BD2, patches 0x0BF4 with their fixed sub-chunk vocab — full id inventory in max_geometry_formats Part A), and the Edit Patch vert mapper. The Mesh cache chunk ids pass through raw.[NELLIGO]* helper nodes, take non-frozen RklPatch nodes (empty node chunk 0x0976 = frozen — the boundary-repetition reference zones), require exactly one (else error, like selectAllPatch).EPVertMapper::UpdateAndApplyDeltas: records are {BOOL originalStored; int vert; Point3 original; Point3 delta} (field order matters — see the B.3 correction in max_geometry_formats); the refresh pass forces originalStored=TRUE for every mapped record within the input's counts, so output = input + delta for those; the stored original/originalStored only matter for records past the input's counts. Paint applies its final patch verbatim. The reference batch exports ran Max in silent mode, so no display-path RPatchMesh::UpdateBinding refresh happens — stored data already carries the bits of Max's last interactive refresh; an env-gated long-double replication of UpdateBindingPos/computeInteriors exists in the tool (PMB_ZONE_REFRESH_BINDS) but is off by default because enabling it reduces corpus identity.RPatchMesh::exportZone (rpo2nel.cpp) in Max float math: triple-edge check, world transform via objectTM (object-offset chunks over the node PRS at t=0), OrderS/T from tile counts, TileColors 565, binds pass 1 (BIND_SINGLE/25/75 with getCommonVertex/getOtherBindedVertex) + pass 2 (edge patch lists), tile fill (case = Flags&7, case!=0 -> setTile256Info(true, case-1); displace = the v9 Noise byte; VegetableState AboveWater always; pi.Tiles.clear() per patch — the reference exporter reuses its CPatchInfo and leaks heap/stale bytes, see the masked classes below). Then NL3D CZone::build + serial to CMemStream, patching version byte 5->4 (v4/v5 differ only in that byte, verified empirically).zonematerial-<mat>-<cell>.max -> 1 brick (zone=<mat>-<cell>, material=<mat>); zonespecial-<name>.max -> 1 brick (material=special); zonetransition-<a>-<b>-<t>.max -> 9 bricks via grid classification ((int)(center/cellSize) through the TransitionIds table), instance transform = zero pos -> optional scale[-1,1,1] -> rotZ(90deg*rot) -> translate; symmetry/rotate go through CPatchInfo::transform with the ecosystem smallbank. Mask = CZoneTemplate::build replication (world verts, open edges = patch count < 2, symmetry swap); passable = appdata 1304892483 presence; filled/square/size computed from the mask; XML via COXml with key-sorted category map. Every produced .ligozone in the corpus is byte-identical to its reference.CPatch ctor now zeroes Flags; CPatch::resetTileLightInfluences now sets PackedLightFactor; CBindInfo ctor now zeroes Next[]/Edge[]. The reference files still carry the garbage, so the comparator masks: CTileElement empty-layer rotate bits + bit 15, CPatch::Flags outside the 0x3c smooth mask, PackedLightFactor, unused CBindInfo slots (valid = NPatchs==5 ? 1 : NPatchs).CZone::build's bbox scale/bias and uint16 packing round 80-bit intermediates once where the x64 SSE build rounds twice -> +-1 packed-unit word flips at quantization boundaries (882 words over the whole corpus, <= ~40/file) and header float deltas <= 5e-5. Same family as the ig POS_EPS whitelist; zone_corpus.py classifies these as x87 passes.zonematerial-converted-202_dy (two non-frozen RklPatch nodes — 202_DY + leftover copy 202_DY01; the original exporter errors on this too, the existing reference predates the copy; the tool's refusal is correct); zonematerial-foret-25_landmark_d_ring2 + zonematerial-converted-200_dz (stacked NeL Edit + Paint + NeL Edit chains where a handful of tangent handles differ at cm scale, length-preserved about their knots — the in-Max stacked-modifier evaluation applies a refresh not yet pinned; needs a Max-side differential probe, same channel as the biped/primitive datasets).zone_corpus.py --all --gate-t3, ctest pipeline_max_zone_corpus, self-skip 77): 1201 corpus files (4 ecosystems), T1 1201/1201, T2 1201/1201, T3 = 1068 byte-exact under the masks + 130 x87-tier (882 word flips corpus-wide) + the 3 budgeted cases, gate exit 0; .ligozone 100% byte-identical.dumpRpoTiles (tile/color writes into the 0x08FD blob + 0x0BF4 colors) plus the modifier-stack final-patch write path.A "review the zone export coverage in depth, the goal that all files export accurately including anything not currently tested, and refine the floating-point precision under exact Max 2010 build conditions" round. Three conclusions, each corpus-measured.
Coverage is complete and correct — nothing exportable is silently dropped. The 1201-file zone_corpus.py sweep is the entire zone-export target set, and it matches the reference pipeline exactly:
landscape/ligo/{desert,jungle,lacustre,primes_racines}/max) hold 1231 .max files; the sweep takes 1201 and excludes 30. Every excluded file is a genuine non-target, verified against the reference maxscript + 1_export.py:
material-<eco> / transition-<a>-<b> ligo bank template files (they feed the ligo bank — processes/ligo/0_setup.py writes a LigoBankDir/LigoMaxSourceDirectory config over this directory), a separate input to the ligo system, not per-brick zone exports. The corpus driver's zonematerial-/zonetransition-/zonespecial- filter correctly keeps them out. Not defective — do not "fix."zonematerial- files with a dash in the cell name (zonematerial-jungle-jungle-2, zonematerial-solprimer-solprimer-03_dome → 4 tokens, the maxscript's tokenArray.count == 3 and the tool's tokens.size() == 3 both reject them), and the dev file corrupted_moor_meta_goo (a development artifact named after the "corrupted moor" zone + the goo biome; no zone prefix). No reference .zone exists for any of the three (only a stale .max.tag).main.cpp: tokens.size()==3 && "zonematerial" etc.) is the same rule as nel_ligo_export.ms's filterString ... "-" / tokenArray.count — confirmed by reading the maxscript. So a brick the reference exported is exactly a brick we export.--zone / ExportRykolZone, processes/zone/1_export.py) has zero corpus: ZoneSourceDirectory = ["landscape/zones/"+ContinentName] but landscape/zones/ is absent (Ryzom uses ligo, not the old snowballs-style direct zones). Correctly untested.So the parser needs no expansion for coverage — every zone .max that should export does. The accuracy frontier is the 130-ish x87-tier + 3 budget, not untested files.
The VS2008/x87 build is the precision instrument, and it closes 36 of the 131 x87-tier files. Full-corpus T3 through ~/build_vs2008_wine_pipeline (zone_corpus.py --bin .../winebin, the same wrapper the skel/anim *_vs2008 tests use; --bank and the input path are translated to Windows form by the wrapper):
| build | exact | x87-tier | word flips | budget |
|---|---|---|---|---|
| x64/SSE (everyday) | 1067 | 131 | 912 | 3 |
| VS2008/x87 (Max-2010-matching) | 1103 | 95 | 872 | 3 |
Matching Max 2010's x87 codegen moves 36 files to byte-exact (1067→1103) and removes ~40 word flips. Notably the flip count barely moves (912→872) while 36 whole files go exact — those 36 each carried only 1–2 pack-boundary flips that x87's extended precision resolved; the remaining 95 x87-tier files hold ~872 flips that persist under true x87 too. That residual is the "our current-tree CZone::build source vs Max 2010's actual NeL build" gap (§2b) — codegen-independent, unreachable from Linux. T1/T2 are byte-identical under x87 (the chunk parser/writer never recomputes a float). Wired as a standing regression gate: pipeline_max_zone_corpus_vs2008 ctest (mirrors pipeline_max_skel_corpus_vs2008 / _anim_), self-skips 77 without the build tree.
A long-double CVector3s::pack refinement was tried and reverted — matching only the pack step to extended precision is a net regression. The divergence mechanism is CVector3s::pack's (v-bias)/scale (nel/include/nel/3d/patch.h): x87 keeps the quotient at 80-bit extended and truncates once, where SSE float rounds the subtraction then the division — two roundings that can tip a value on an integer boundary by one packed unit. A standalone brute-force confirmed ((long double)v-bias)/scale does diverge from the float quotient exactly at those boundaries (≈0.014 % of random zone-like triples, each a ±1 packed flip), and long double on x86-64 gcc is 80-bit x87-extended (the FPU handles it even when float/double are SSE), so in principle a long-double pack reproduces the reference's single-rounding truncation on the everyday x64 build. Measured result: a long-double pack alone is a net regression — 575 exact + 623 x87 / 3418 flips, far worse than the 1067/131 baseline. The flip counts tell the shape of it: x87 (extended transformPoint and extended pack) lands at 872 flips; x64 (SSE transformPoint + float pack) at 912 — close to x87; x64 (SSE transformPoint + extended pack) at 3418. The two precision sources (the tool's object-TM application and the engine's quantization) must be matched together; matching only the downstream pack while the upstream transformPoint stays SSE moves the truncation boundary for the SSE-rounded vertices and produces more, not fewer, flips. The only route that helps is matching the whole chain — which is exactly what the VS2008/x87 build does (gcc ≠ MSVC-2008 register-spill points would make an x64 long-double chain approximate, never exact, the same wall §10l hit for skel InvBindPos) — for a modest 36-file ceiling. Reverted; patch.h is unchanged (verified: the x64 baseline reproduces 1067/131/912 after the revert). The x64/SSE build sits at the documented floor; the VS2008/x87 build is how the 36 codegen-closeable files are reached, now a gated regression check rather than a manual one-off.
The two stacked-modifier budget cases are not precision, and not the binding refresh. Probed directly on zonematerial-converted-200_dz (the worst, +6 packed units ≈ 1.7 cm on one interior handle): (a) the VS2008/x87 build fails it at the identical byte (0x5bf95) as x64 — codegen-independent, so not the x87 class; (b) PMB_ZONE_REFRESH_BINDS (the long-double UpdateBindingPos/computeInteriors replication) produces the same 7-word diff — the binding refresh isn't the lever either; (c) the node 200_DZ is RklPatch + a NeL Edit Patch modifier (OSM Derived → NeL Edit (0x4dd14a3c) + RklPatch), so the §10h "stacked modifier" characterization is confirmed present (not a plain RklPatch); the diff is a handful of tangent/interior control points in one patch, length-preserved about their knots. Root cause is a stacked NeL-Edit-Patch evaluation difference that is codegen-independent and refresh-independent — pinning it needs a Max-side differential probe (same channel as the biped/primitive datasets). The duplicate-node case (converted-202_dy) is correct refusal (§1.1). All three stay budgeted; the precision and refresh avenues are now ruled out for the two tangent cases.
pipeline_max_export_shape replicates the shape process (processes/shape, NelExportShapeEx → CExportNel::buildShape, plugin_max/nel_mesh_lib/export_mesh.cpp): one .shape per eligible node, filename = lowercased node name, output routed to the with-coarse-mesh directory when any LOD-set member carries NEL3D_APPDATA_LOD_COARSE_MESH. Corpus = every .max under the workspace ShapeSourceDirectories of the projects running the shape process: 2850 files across 26 projects; references = the fresh 1_export run at ~/pipeline_export/<group>/<project>/shape_not_optimized + shape_with_coarse_mesh (3518 + coarse refs). The tool is structured as reusable units for the next exporters: scene_lib (scene load/registry, appdata, old ParamBlock + ParamBlock2 decode, controller values at t=0, node TM cache, object-chain/XRef resolution, database-path resolution), mesh_eval (EditableMesh extraction + modifier stack: Edit Mesh, XForm), material_build (NeL material v14 → CMaterial), mesh_build (CMeshBuild/CMeshBaseBuild + Max render normals), main (selection, dispatch, serialization, --compare field-level diff with verdicts).
Bip*-prefixed node or root names, RklPatch, nel_ps (PartA 0x58ce2893), nel_pacs_box/cyl, Target (0x1020,0) — 0 of 3518 references are targets; accelerators (accel appdata ∉ {"0","32"}), DONOTEXPORT/COLLISION/COLLISION_EXTERIOR == "1", and LOD-slave nodes (listed in any node's NEL3D_APPDATA_LOD_NAME entries, case-insensitive).{u32 version, u16 blockId, u16, u16 0x2328, u16 paramCount, u32 ownerIndex}; param records 0x000e = {u16 paramId, u16 type, 10B, u8 flags, payload}; flag 0x40 = inline constant (float/int/bool/RGBA/string per type; TYPE_BITMAP's filename rides the record's 0x0003 sibling container as BitmapInfo blob + UTF-16 path); records without 0x40 (controller-backed) and reference-kind types (TEXMAP/REFTARG, payload = slot or −1) consume the PB2's reference slots in record order.material_build.cpp, replicating buildAMaterial): the scripted NeL material ((0x64c75fec, 0x222b9eb9), extends Standard) = delegate ref 0 + 9 PB2s in script declaration order (nlbp, main, textures, slot1-4, slot5/6 empty); the whole corpus is script version 14 (first dword of chunk 0x0010), the v14 param-id table is in the code; texture slots via tTexture_1-8 TEXMAP refs → BitmapTex ((0x0240,0): crop from bmtex_params 0-3+6, file from param 13's bitmap container, UVGen = delegate ref superclass 0xc20 — mapChannel chunk 0x900b, wrap flags 0x9002 bits 0/1) and NeL Multi Bitmap ((0x5a8003f9, 0x043e0955): bitmap1-8FileName strings → always CTextureMultiFile, replicating the reference's dead single-slot branch); shader/blend/zwrite/specular×level/shininess=2^(gloss·10)·4/self-illum/user-color/texEnv-stage semantics per the reference source; lightmap channel appends the extra RemapChannel. Multi/Sub-Object ((0x200,0)): sub count 0x4001-name/0x4002-count, refs 1..N.alwaysOne/smoothingGroups are actually smGroup and faceFlags) + map channels keyed by 0x0959 (0 = color, 1+ = UV; support 0x2398, verts 0x2394, faces 0x2396); Edit Mesh modifier deltas (§10g decode) with map-face sync; XForm gizmo. Vertices to node-local space via objectToLocal = objectTM·Inverse(nodeTM) through NeL CMatrix (reference-exact float path); corner normals through CPlane * fromExportSpace + normalize.weight · normalizedFaceNormal into the vertex's RNormal list (first smoothing-bit match merges + ORs groups — first-match without the OR, and exact-equality matching, both change VB dedup counts and fail), normalize at the end; getLocalNormal picks face normal for smGroup 0, the single RNormal when there's one, else first bit-match. The whole computation must run at extended precision without intermediate float stores (long double; the reference is a 32-bit x87 MSVC build): rounding the normalized edges to float before the dot amplifies through acos near 0/π to ~3e-4 normal error (observed exactly), vs ~3e-7 when kept wide.nlstop'd): CVertexBuffer::SerialOldPreferredMemory / CIndexBuffer::SerialOldPreferredMemory static flags — when set (export tools only), serialHeader writes version 3 (VB) / 2 (IB) with TBufferUsage mapped back to the old TPreferredMemory enum values. The CMeshBase version 10 byte ("Ryzom Core release check", no fields over v9) is patched 10→9 post-serialization, same class as the zone v4/v5 byte (§10h). Stream layout: "SHAP" + u64 1 + u32 len + className + classVersion byte + meshBase version byte.--compare, used by the harness): byte-identical → IDENTICAL; else field walk (materials serialized individually and byte-compared, DefaultPos/RotQuat/Scale, VB per-value with ULP+abs stats, matrix blocks/rdr passes/index content) → FLOATEQ when every difference is float noise (abs ≤ 2e-6 or ≤ 32 ULP — the x87-reference tier, same family as §10g POS_EPS / §10h x87) → else DIFF.mapext198m3.dlm, "Map extender plug-in — 3DSMax Team - UBI Soft Romania & Daniel Raviart", modifier (0x2ec82081, 0x045a6271)) is the §9-pre-triaged garbage-UV class — the exporter tags affected nodes (MAPEXT lines), the harness routes their shapes to a dedicated bucket outside the diff gate and emits the asset list report (mapext_assets.txt: project, file, node).shape_corpus.py --all --gate-t3, ctest pipeline_max_shape_corpus, self-skip 77; maxole.py — a pure-Python OLE2 + chunk-walker survey tool — landed alongside): T1/T2 2850/2850 green (first full shape-corpus roundtrip validation). T3 milestone 1 (plain CMesh, no skinning/multilod/water/remanence/flare/interface/morph): 2141 shapes exported — 704 float-noise-eq vs references + 64 mapext-bucketed + 989 lightmap-bucketed (LightMap-shaded materials: the reference carries export-time lightmap textures + calc-lm-modified fields, the headless export is unmapped by design — bucketed until the standalone lightmapper exists; a masked structural compare for this bucket is a good next refinement), 383 differ (gate budget 400), 1449 reference shapes not yet produced (skip classes: skinned 654, multilod 418, water 221, remanence 82, mesh-eval 65, flare 13, interface 9). Byte-identical is structurally impossible against the x87 reference build for any rotated/offset node (decomp_affine quat/scale ULPs, VB position/normal last-ULP noise) — FLOATEQ is the equality tier, like the skel/zone precedents.Handoff notes for the next shape session (2026-07-07, written at M1 close):
Reference provenance (Kaetemi, 2026-07-07 — pins two decisions below): the corpus .max files are Max 3-era assets upgraded through Max 9 (that's the FILE format we parse), but the ~/pipeline_export reference outputs were exported by loading them in Max 2010, which is a VS2008 x86 build. Consequences: (1) runtime-EVALUATION probes (modifier semantics, UVW Map gizmo fit, normals edge cases) should target Max 2010 — the Max 9 differential-dataset channel remains authoritative for storage-format questions only, and the places where Max 9 evaluation matched the references (prim topology) were verified coincidences, not a rule; (2) the codegen-matching experiment (below) targets the VS2008 toolchain + the Max 2010 SDK's static libs (decomp_affine is Autodesk-compiled SDK object code — linking it in a VS2008-under-Wine kernel harness is the only route to bit-exact DefaultRotQuat/Scale; Max-core code like Mesh::buildRenderNormals lives in Max 2010's own DLLs and is not reproducible by any recompilation — normals stay in the 1e-4 tier regardless).
Working method that paid off (reuse it): iterate per diff class with shape_corpus.py --t3 --project common/objects -j 10 (~4 min) rather than full sweeps; categorize the DIFF output with a throwaway script grouping by (material/vertcount/format/quatneg/vbN-big) — the category sizes tell you what to attack; per-file triage via the exporter's debug envs: PMB_DUMP_MTL=1 (material/PB2/refs structure dump), PMB_COMPARE_DUMP=big (VB verts with >0.01 diffs incl. positions, to map back to mesh verts), PMB_COMPARE_DUMP=mtl (per-material field dump A vs B). maxole.py answers "what class/DLL/chunks does this file carry" in one python call (ClassDirectory3 names, DllDirectory, chunk walks). Do NOT rebuild binaries while a sweep runs.
UVW Map (0xf72b1), the biggest UV class: standard old-style ParamBlock modifier (read with readPBlockParams, ints-as-ints trap applies). Per-node gizmo = the OSM Derived wrapper's 0x2500 slot (0x2510 mod-context TM, like Edit Mesh/Mirror in §10g). Expect the bbox "fit" to be baked into the context TM at authoring time (UVW Map fits its gizmo on creation — unlike Mirror's identity default, but VERIFY against a corpus case, that burned us once). Params to expect: map type (planar/cyl/sphere/box), length/width/height, U/V/W tile+flip, channel. Implement planar first, validate against a file whose only warning is UVW Map; the projection math is the SDK's UVWMapper::MapPoint semantics.
Unidentified modifier classes 0x40c7005e, 0x8ab36cc5, 0x8aad7d94: first step is just naming them via maxole (ClassDirectory3), then checking whether they're geometry/UV-neutral — find files where one of them is the only warning and see if the shape is already FLOATEQ (then whitelist as neutral, like UVW Map was for ig).
lanceroquette-class normals (maxAbs ~0.5, a handful of corners): multi-smoothing-group corners where the RVertex pick/merge diverges. The dedup counts prove first-match+OR-groups is right in aggregate; the residue is likely either a second merge pass in Max's buildRenderNormals (RNormals sharing bits after accumulation) or getLocalNormal picking a different RNormal when several match. Ground-truth method: PMB_COMPARE_DUMP=big gives the VB vert + position → locate the mesh vertex via maxole → enumerate adjacent faces/groups → compute candidates in python against the reference value (that's exactly how angle-weighting was found).
Parametric primitives as export meshes (obj:0x10/0x11/0x12/0xa, ~50 nodes): reuse buildParametricMesh from pipeline_max_export_ig/main.cpp (GT-exact topology). The missing piece is generate-mapping-coords UVs — the prim dataset (~/prim_mesh_dataset) has topology only; extend gen_prim_mesh_dataset.ms (Max-side probe channel) to dump map channel 1 per primitive if UV formulas are needed. obj:0xa and 0x1040/0x1065 need naming first.
EditablePoly (13): CGeomObject::triangulatePolyFace + the poly buffers decode already exist (§7; pipeline_max_dump's exportObj is the working example); shapes additionally need the poly MAP channels (max_geometry_formats has the poly chunk vocab).
candide / duplicate node names: two nodes sharing a name → same output filename, last write wins; the reference's write order is the maxscript enumeration (Max node order), ours is container order. Compare which node the reference matches and replicate (likely: walk the node TREE like ig's selection, not the container).
M2 order of attack: CMeshMultiLod (418 skips — LOD_NAME slave meshes + coarse flags, buildMeshMultiLod in export_mesh.cpp; slaves resolve by node name case-insensitive; biggest payoff, mechanical); CWaterShape (221 — buildWaterShape, polygon + pool id + env map params from the bWater material); CSegRemanence (82 — buildRemanence + USE_REMANENCE appdata); unskinned CMeshMRM (~24 — buildMRMParameters already implemented, just wire CMeshMRM::build; NL3D's MRM builder is deterministic but float-heavy, expect a wider FLOATEQ tier); CFlareShape (13 — buildFlare, nel_flare scripted-plugin PB2 like nel_ps); interface meshes (9 — INTERFACE_FILE appdata, border-weld of normals against the interface .max).
M3: Physique → CMeshMRMSkinned (654): per-vertex bone weights live in the Physique modifier's per-node 0x2500 slot (undecoded); bone names/order need buildSkeletonShape's nodeMap semantics (share pipeline_max_export_common); §10's Physique interface notes (IPhyRigidVertex, offset vectors bone-local) are the semantic reference. Morpher blend shapes: modifier refs 101+i = target nodes (§10d), getBSMeshBuild evaluates targets through the same mesh path.
The parse&modify&save / material-editor goal (task list #3): everything the M1 session decoded lived in read-side helpers (scene_lib.cpp, material_build.cpp) reading orphaned chunks — pipeline_max itself had no typed material classes, which is why T2 stayed trivially green. Started in §10j (2026-07-07): typed CParamBlock2 landed (the keystone — all material params live in a ParamBlock2), with the exporter's PB2 decode retargeted onto it. Still to type over that foundation: CStdMat2/CBitmapTex/CMultiMtl/CTexmap + the scripted NeL-material class (the empty stubs of §7), then geometry buffers. The exporter's decode tables are the format spec, and each typed class must re-gate T2 over the full corpus (§12 rules; the 8.6k-file sweep, not just the shape corpus).
Do not chase byte-identity on T3: the reference plugin is a 32-bit x87 MSVC build; FLOATEQ is the equality tier (2e-6/32-ULP; normals 1e-4; quat double-cover). The zone/skel sessions reached the same conclusion independently. A -m32 -mfpmath=387 build was considered and rejected (2026-07-07): x87 rounding points are register-allocator codegen (gcc ≠ MSVC-2008 spill points — the reference host is Max 2010, a VS2008 x86 build, see the provenance note above), the Windows CRT runs the FPU at _PC_53 (not 80-bit), and the CRT transcendentals (acos in the normal path, fpatan-based and CPU-generation-specific) differ from glibc at exactly the tail ULPs the accumulations amplify — so it buys proximity on some chains, never identity. A VS2008-under-Wine (or on the Max box) kernel-probe harness linking the Max 2010 SDK libs is the scoped experiment if the transform classes ever need bit-exactness — environment setup is documented at VS2008 on Linux using Wine. If byte-level regression hashing is ever wanted, snapshot OUR deterministic x64 output as the golden set and keep the Max references for FLOATEQ semantic validation only. Lightmapped shapes (989) stay bucketed until the standalone lightmapper exists; a masked structural compare for that bucket (mask the appended lightmap textures + calc-lm-touched ambient/specular/shininess) would upgrade them from "bucketed" to "verified-unmapped" and is a good early task.
pipeline_max (2026-07-07, editor-direction session)The parse&modify&save / NeL-material-editor goal (task list #3, §10i handoff): the format knowledge the shape exporter accumulated lived only in read-side helpers (scene_lib.cpp, material_build.cpp) that decode orphaned raw chunks, so pipeline_max proper gained no typed material classes and T2 never actually exercised material/param serialization. This session begins moving that knowledge into typed scene classes in the library itself, so a consumer (the exporter today, a live material editor tomorrow) can load → read → modify → save byte-exactly, and so T2 genuinely gates the parsed↔serialized roundtrip of that data.
Milestone: typed CParamBlock2 (builtin/param_block_2.{h,cpp}). ParamBlock2 (superclass 0x82) is the keystone — every material/texmap parameter (NeL-material v14 blocks, StdMat2 shader/extended/maps blocks, BitmapTex crop/UVGen, the scripted PS/flare params) lives in a ParamBlock2, not in the material's own chunk stream (which carries only the 0x4000 base + reference wiring; see max_geometry_formats Part I). The typed class:
CControlKeyFramerBase/CRklPatchObject (§5, §12.2): parse decodes a typed model over the orphaned chunks without moving them, build re-emits them verbatim, so roundtrip is byte-exact for every unmodified block by construction. It registers for the whole 0x82 superclass (CSuperClassDescUnknown<CParamBlock2, 0x82> replacing the former <CReferenceTarget, 0x82>), so every ParamBlock2 in every .max now parses through it regardless of class id.type & 0x7ff); reference-kind types (MTL/TEXMAP/NODE/REFTARG) and controller-backed non-constant params own the block's reference slots in record order; tab params (type bit 0x800) carry u32 count + per-element flag+value and own no reference slot. Exposes typed read accessors (getFloat/getInt/getBool/getColor/getString, refValue, params()) plus the header fields.setFloat/setInt/setBool/setColor rewrite only the value bytes inside the owning 0x000e chunk, leaving the record's opaque header and every other chunk untouched, so a modified block still roundtrips byte-exact except for the changed value. This is the write half of the material-editor path (string/tab authoring is still read-only, deferred until a corpus case needs it).pipeline_max_corpus_test --pb2-selftest <file.max> parses the scene and, for every ParamBlock2 object, re-encodes each fixed-size scalar/color parameter from its decoded value and checks it against the stored bytes (validates the payload offsets and the modify path). Over the full shape corpus: 2850 files, 997 589 ParamBlock2 objects, 8 650 727 parameters, 0 mismatches.The exporter's scene_lib::readPB2Block now delegates to CParamBlock2's typed model (the inline orphan-chunk decode is deleted — one decode path, in the library), and material_build is unchanged (it consumes the same SPB2Block). Gate status: shape corpus T1/T2 2850/2850, T3 unchanged (767 float-noise-eq + 320 differ, same as before — the retarget is behavior-preserving), and a 1097-file wide random T1/T2 sample across the whole .max corpus is clean (the 0x82-superclass retarget touches every material-bearing file, not only shapes).
End-to-end modify&save (the "programmatically adjust existing .max files" capability). pipeline_max_corpus_test --modify-save-test <file.max> proves the whole loop: load a .max, change one ParamBlock2 parameter through CParamBlock2::set*, write the whole .max back (Scene rebuilt from the typed scene graph via the OLE envelope — serializeStorageContainer/serializeRaw from pipeline_max_rewrite_assets, every non-Scene stream copied verbatim, OLE class id preserved), reload it, and assert (a) the parameter reads back the new value, (b) every non-Scene stream is byte-identical to the original, (c) the Scene stream differs only in the modified parameter's payload — a surgical, byte-localized edit. Over the full shape corpus: 2850/2850 pass (the edit is exactly the 4/12 payload bytes, everything else byte-for-byte identical). This is the file-level foundation the standalone material editor (load scene → read/modify params → live preview via the exporter's buildAMaterial → save) sits on.
Two roundtrip-coherency defects fixed along the way (both "ours to fix", surfaced while validating modify&save):
shape_corpus.py/ig_corpus.py invoked the tester as [corpus_bin, path, "--parse"] (flag after the path), but pipeline_max_corpus_test only parsed leading -- flags, so --parse was ignored and T2 re-ran T1 — "T2 2850/2850" was meaningless. Fixed both ways: the tester now accepts flags in any position, and the two drivers pass --parse first. (zone/anim/skel/swt/regen always passed the flag first and were genuinely running T2.) With T2 actually running, the real shape T2 is 2850/2850 — the no-op had masked exactly one genuine failure:CReferenceMaker dropped trailing empty (-1) reference slots on rebuild. build() emitted nbReferences() entries for the 0x2034 flat array, but subclasses that route references into their own vector (CTrackViewNode::m_Children, CSceneImpl's fixed slots) grow that vector only up to the last non-empty slot — so a TVNode with two empty trailing slots (in sfx/meshtoparticle/birda.max) re-emitted a 10-entry array where the source had 12, an 8-byte-shorter Scene stream. Fixed by recording the source 0x2034 array length (m_References2034Count) at parse and re-emitting at least that many entries (the trailing ones resolve to −1 through getReference). Verified: birda T2 green, whole shape corpus T2 2850/2850, wide cross-corpus T2 sample clean, no regression.(A tester hygiene fix rode along: the PID-named T2 temp file was never deleted, so a full sweep leaked ~2850 temp files — it now remove()s its temp at every exit path.)
With the harness actually running T2 again, the full ~8.6k-file corpus was swept: 8632/8632 T2 green, 0 fail (2 git-lfs pointer stubs) — genuine roundtrip coherency across the whole corpus (the CParamBlock2 0x82-superclass retarget and the reference-array fix are both corpus-wide changes; this is the design-doc §12.1 full-corpus gate, not just the shape subset).
Typed material/texmap classes (CMtlBase + CMultiMtl). On top of the CParamBlock2 keystone, the material tree is now typed. Every material and texmap shares CMtlBase (builtin/mtl_base.{h,cpp}), which decodes the one payload common to all of them — the material-base name (chunk 0x4001, raw UTF-16, bare on the object or nested in the 0x4000 base container; §I). Registered for both the material superclass 0xc00 and the texmap superclass 0xc10 (CSuperClassDescUnknown<CMtlBase, 0xc00>/<…, 0xc10> replacing the former CReferenceTarget reps), so every material and texmap in every file parses through it. The Multi/Sub-Object material (ClassId {0x200,0}) is exact-typed as CMultiMtl (builtin/multi_mtl.{h,cpp}): sub-material count from chunk 0x4002, sub-materials on reference slots 1..N (slot 0 = its own ParamBlock2). Raw chunks stay authoritative (verbatim build → byte-exact roundtrip). Everything that distinguishes a concrete material — the StdMat2 shader/maps/extended blocks, the BitmapTex crop/bitmap, the NeL-material v14 flags — is already reachable through the reference wiring + the typed CParamBlock2, so a consumer now has a fully typed material tree: enumerate (scan superclass 0xc00), identify (classId), name (CMtlBase::name), walk sub-materials (CMultiMtl) and textures (references) and parameters (CParamBlock2). pipeline_max_corpus_test --mtl-dump prints it: e.g. a Multi material 'Material #14' → 5 sub-materials, BitmapTex + NeL-Multi-Bitmap texmaps named, StdMat2 materials named. The exporter's material_build::materialName is retargeted onto CMtlBase::name() (kept verbatim — no trailing-null strip — so animated-material names stay byte-exact); gate: shape T1/T2/T3 unchanged, full-corpus T2 green.
Typed geometry buffers (PMBS_GEOM_BUFFERS_PARSE enabled). The tri/poly vertex+face buffers inside GeomBuffers (0x08FE) are now typed leaf arrays rather than raw byte blobs — the 2012 serializers (CStorageArraySizePre<CVector> 0x0914 verts, CStorageArraySizePre<CGeomTriIndexInfo> 0x0912 tri faces, CStorageArrayDynSize<CGeomPolyFaceInfo> 0x011A poly faces with the variable bitfield record, plus the secondary tri/poly buffers) finally switched on, the mesh-edit half of parse&modify&save. This is the first real corpus test of those leaf serializers (§12.5(d)) and they pass: full ~8.6k-file T2 8632/8632 — every byte reproduced, including the poly-face bitfield path on the EditablePoly files. CGeomBuffers gained typed triVertices()/triFaces() accessors; the two consumers that read those chunks raw (shape mesh_eval::extractEditableMesh, ig extractObjectMesh) are retargeted onto them (a bulk copy — CVector/CGeomTriIndexInfo share the byte layout of the consumers' Point3M/face structs — so the export bytes are unchanged: shape T3 767 float-eq + 320 diff unchanged, ig 53/54 field-exact unchanged). Note the CGeomTriIndexInfo field labels alwaysOne/smoothingGroups are the 2012 guesses; the corpus-validated meaning is smGroup at offset 12, faceFlags (matID in the high word) at offset 16 (§10i) — roundtrip doesn't care (5 dwords either way), consumers read the corpus-correct offsets. The map-channel chunks (0x2394/0x2396/0x0959) stay raw for now (not in the typed set); typing them is the next geometry step if a UV-edit path needs it.
t=0 controller resolution (landed). A property may be a constant or controller-backed-but-correct-at-t=0 (an animated material scalar, a keyed parametric dimension); the value the export/editor wants is the controller's value at tick 0. That key-bracket-at-tick-0 evaluation moved out of the exporter's scene_lib and into the library keyframer classes — CControlKeyFramerBase gains floatValueAt0/posValueAt0/rotValueAt0/scaleValueAt0 (bracket the key table at tick 0, linear-interp for Linear controllers, else the default-value chunk; raw-float form, no external math types). scene_lib::{pos,rot,scale,float}ValueAt0 now wrap those (keeping the raw-chunk fallback for non-keyframer PRS sub-controllers), so every existing skel/anim/ig/shape gate that samples a controller at t=0 validates the library eval — skel T3 came out bit-identical (drot 11059+3, dpos 4420+4099), shape/ig T3 unchanged. CParamBlock2 gains getFloatAt0/getColorAt0: return the inline constant, else resolve the param's reference slot to its keyframer and evaluate at tick 0 — the read path an animated material param (or the live material editor) needs. Full-corpus T2 stays 8632/8632.
Next: the concrete CStdMat2/CBitmapTex blocks + name/string authoring (variable-length modify, shared with PB2 strings) if a modify path needs them; the map-channel chunks (0x2394/0x2396/0x0959) for a UV-edit path; and the exporter output-coverage frontier (CMeshMultiLod 418, water 221, skinning 654) which is orthogonal to the parser/editor work above.
The waterfall materials are the animated-material case, and they revise §10d's "material/texture tracks — zero corpus signal" conclusion. The waterfall cascade geometry (not the water surface, which is a CWaterShape; the NeL Material class 0x64c75fec is generic, and the water surface animates engine-side via CWaterModel) uses an ordinary NeL Material whose texture matrix is animated — the UV offset scrolls so the texture appears to flow. §10d's survey missed it for two reasons: (1) it checked the node appdata NEL3D_APPDATA_EXPORT_ANIMATED_MATERIALS, which gates material color tracks, whereas texture-matrix tracks are gated by the material PB2 param bExportTextureMatrix; (2) it swept the anim-process corpus, but these tracks are emitted by the shape process (the reference .anims live under ~/core4_data/{jungle,matis}_shapes/waterfall*.anim, e.g. tracks Material #26.VTrans0, Material #27.UTrans0).
Mechanism (reference export_anim.cpp:826-977 addTexTracks, export_material.cpp:520): for a material with bExportTextureMatrix, per enabled stage (bEnableSlot_N) read the texmap (tTexture_N) and pull its StdUVGen controllers by name — "U Offset"/"V Offset" (→ .UTrans<stage>/.VTrans<stage>), U/V tiling (→ .UScale/.VScale), "W Angle" (→ .WRot) — building CTrackKeyFramerLinearFloat tracks (CAnimatedMaterial::getTexMat*Name).
Scene structure (via pipeline_max_corpus_test --uvgen-dump): BitmapTex (0x240) → ref0 StdUVGen "Placement" (0x100) → ref0 old-style ParamBlock (0x8) → ref-slot = Linear Float controller (0x2001, sc 0x9003). The StdUVGen coordinate params live on an old ParamBlock (not a PB2); an animated one holds a Linear Float controller in its slot.
Step 1 landed — Linear Float controller typed (§10b). CControlFloatLinear (0x2001), the one float controller our keyframer set lacked, keyed off chunk 0x2511 ({time,flags,val} 12B, 2 keys on the waterfall = a scroll loop over 16000 ticks). Gated full-corpus T2 8632/8632; floatValueAt0 now resolves it too.
Step 2 landed — StdUVGen ParamBlock slot mapping. Each StdUVGen coord param is a 0x0002 entry {0x0003 index, 0x0004(empty), value}; the animated param replaces its value chunk with an empty 0x0200 and its controller is the ParamBlock's reference (compact — one ref per animated param, in entry order). Correlating the waterfall against its reference anim (Material #26 → VTrans, #27 → UTrans): U Offset = param index 0, V Offset = index 1 (U Tiling 2 / V Tiling 3 / W Angle by the standard StdUVGen order, unexercised → per §12.2 implement offset first, validated). --uvgen-dump prints the param entries.
The export path. The shape process (not the anim process) emits these: shape_export.ms runs NelExportAnimation #(node) <node.name>.anim false per node with NEL3D_APPDATA_AUTOMATIC_ANIMATION != "0"; material tracks come through addMtlTracks, gated by the node's NEL3D_APPDATA_EXPORT_ANIMATED_MATERIALS (color tracks) with the texmat part additionally gated by the material's bExportTextureMatrix — so §10d's "no EXPORT_ANIMATED_MATERIALS anywhere" held for the anim corpus but the shape corpus (waterfalls) sets it. NelExportAnimation is the tool I replicated in pipeline_max_export_anim; the typed material tree (CMtlBase/CMultiMtl/CParamBlock2) makes the node→material→texmap→StdUVGen walk tractable there.
Steps 3-4 landed (byte-exact). pipeline_max_export_shape now writes the per-node <node>.anim (the shape process's NelExportAnimation step, gated AUTOMATIC_ANIMATION; new --anim-out). anim_build walks the node's material tree (typed CMtlBase/CMultiMtl), and for each material with bExportTextureMatrix resolves its texmap's StdUVGen U/V Offset Linear Float controller → CTrackKeyFramerLinearFloat named <mtl>.UTrans<stage>/.VTrans<stage>. Subtleties that had to match exactly: a Multi resolves its OWN track through its first reachable NeL sub-material (getValueByName/getControlerByName recurse subanims) and recurses subs before its own texmat (the reference insertion order, which CAnimation preserves as the track-data index behind the name-sorted map); the texmap StdUVGen is found by recursing the reference subtree (plain BitmapTex vs NeL-Multi-Bitmap delegate); value passthrough, time = ticks/4800, range from the controller Interval.
The gotcha — the export flag itself is animated. bExportTextureMatrix (and bEnableSlot_N) are frequently NOT inline constants: on many waterfalls the artist keyed the flag with an On/Off controller (0x984b8d27), so the reference reads the live value at t=0. Shared SCENELIB::resolveNelBoolAt0 (scene_lib) resolves the constant-or-On/Off-controller-at-tick-0; material_build uses it too (was nelInt, constant-only) so enableUserTexMat fires — otherwise the material serializes with no user texture matrix for the animation to drive (a real "waterfall doesn't scroll in-game" class our exporter would have shipped). Gate: shape_corpus.py compares each produced .anim byte-exact against ~/core4_data/*_shapes; material anim 67/67 byte-identical, shape T1/T2 2850/2850, T3 unchanged (767 float-eq + 320 diff).
Still open (shape-material byte-match, not animation): the material's static user-texture-matrix value — the reference bakes the StdUVGen's static UV offset/scale/angle into it, we emit identity (the undecoded StdUVGen static transform, §10i). This is a ~48-byte-per-animated-material shape T3 gap; it does not affect whether the material animates (the track drives the matrix each frame). Decoding the StdUVGen static transform closes it. Also open: .UScale/.VScale/.WRot tracks (tiling/rotation) — implemented by analogy but unexercised in the corpus (every animated material keys only U/V Offset).
name-marker investigation (2026-07-07, skel-continuation session)VS2008/x87 corpus gate wired into ctest. The pipeline_max_skel_corpus_vs2008 test (pipeline_max_corpus_test/CMakeLists.txt) drives the exact same skel_corpus.py --all --gate-t3 sweep through the VS2008/Wine x87 reference build (~/build_vs2008_wine_pipeline, see §2b) via a new winebin/pipeline_max_corpus_test wrapper (the export-tool wrappers already existed; the corpus-tester one didn't). Self-skips (exit 77) on any machine without that build, same convention as every other line in that file. Confirmed green: T1 169/169 + 9/9, T2 169/169 + 9/9 (byte-identical to the x64/SSE build — the chunk parser/writer is float-codegen-independent, as expected since T1/T2 never recompute a float, only copy stored bytes), T3 accuracy floor holds (biped size-match 169/169, drot exact 11059+3/11120, dpos exact+close 4420+4099/11120; non-biped dpos/drot 222/222 exact) — numbers matching §2b's already-recorded x87 figures (DefaultPos bit-exact 53/222, byte-match 89.9%). This makes the x87 comparison a standing regression gate instead of a manual one-off --bin invocation.
prs:name-marker investigation (the "~28 markers not COM-parented" item from §10's "Remaining biped work") — ruled out three hypotheses, no fix landed. Using the legacy live-Max GT dump (~/skel_gt) plus direct per-bone diffing against the real .skel reference (not the GT — the GT has its own known mirror-authored divergence, see below), every corpus file with a name-tagged marker parented directly to the biped COM was compared: 131/159 files already match (the existing "PRS children of a biped COM node don't inherit the COM's rotation" composition rule, §10 "Two PRS-path defects fixed", is correct for them); 28/159 don't, and for all 28 dropping the composition entirely (using the controller's own stored-and-inverted value as the final local rotation, unmodified) reproduces the reference bit-for-bit — but doing that for all 159 would flip the ledger to 28 fixed / 131 broken, a net regression. Three candidate discriminators were tested and each falsified by a corpus counter-example:
ControlRotLinear cases all need the fix, but so do 9 of the 131+9=140 ControlRotTCB cases — class alone doesn't split the set.keyCount()==0 — all are reading the same default-value chunk path, no key-table involvement.tr_mo_c03's COM is tilted ~92.5° and works; tr_mo_ryzerb's is tilted ~91.3° (smaller) and doesn't — magnitude doesn't predict correctness either.kami_guide_3/4 + kami_preacher_2/3/4 share one failure signature; all ryzerb/ryzoholok/c06/h07/h11 share another; tr_mo_balduse/tr_mo_clapclap share a third), which reads as a per-template authoring inconsistency (how the artist created/aligned the marker in Max) rather than a decodable file-format rule — consistent with §12.2 ("type only what you can prove"): no formula was found that a corpus counter-example didn't kill, so none shipped. Left open for a session with live Max access (a differential-dataset probe creating markers under a COM via different Max workflows — e.g. Align tool vs manual parent vs "Reset Xform" — would either reveal a stored discriminator bit or confirm this really is unrecoverable per-file). The GT dump's OWN divergence on name/Box0N/Croc D 0N-style markers (quat_dist ≈ √2 against the GT specifically, everywhere the object carries authored negative/mirrored scale) is the pre-existing, separately-diagnosed convention mismatch between MAXScript .rotationpart and our decompose (§10e "Negative-scale (mirror-authored) markers") — not the same phenomenon as this 28-file set, which was diagnosed against the real .skel reference, not the GT.Candidate chunks 0x01f4/0x01f9 (flagged in §10's "Remaining biped work" as a possible per-limb chain-scale factor) — checked, not it. Their values (0x01f4: 0.32/0.74/0.58/0.32/0.30/0.30/0.22 on a sample humanoid; 0x01f9: 1.28/1.08/1.06/0.93/0.67/0.66/0/0/0.076… on the same file) aren't close to 1.0, ruling out "small multiplicative correction on the stored chain length" as their role — more likely DOF-limit or dynamics parameters unrelated to bind-pose geometry. The mm-level chain-position residue itself (checked on tr_mo_c03's legacy-format Spine/Spine1/Spine2, ~0.3mm, well inside the "close" tolerance) shows a small consistent-sign Y-axis "droop" not reproduced by the current formula (~0.05–0.1% of link length) — same unexplained-in-closed-form class as the fresh-format chainEpsShift second-order residue (§10e), just without an FigureVersion==0 dataset to pin it for legacy files. Left open; not worth chasing further without new differential data, since it's already inside tolerance everywhere it was sampled.
Biped InvBindPos via the Max-Matrix3M accumulation chain — tried, confirmed a regression on both codegens, don't re-attempt. The non-biped precedent (§10i: decompMatrix/Matrix3 accumulation is a wash-to-worse for DefaultRotQuat/Scale/InvBindPos, only DefaultPos benefits) was re-tested for biped bones specifically, since biped's local transforms come from getBipedLocal's procedural reconstruction rather than a direct controller read — a priori not obviously the same situation. Prototype (composePRS + convertMatrix chain from each bone's OrigPos/OrigRot/DefaultScale, replacing the NLMISC::CMatrix world-accumulation InvBindPos derivation) was env-gated, corpus-tested on both x64/SSE and the VS2008/x87 build, and reverted after confirming a clean regression on both: InvBindPos bit-exact bones 39→0/11120 (x64), 41→0/11120 (x87); average T3 byte-match 75.7%→66.0% (x64), 75.5%→65.8% (x87); the ULP histogram collapses (<=0 bucket 4.0%→1.2%, >256 bucket 64.1%→98.3%). Confirms the non-biped conclusion generalizes: NLMISC::CMatrix world-chain accumulation stays the right choice for InvBindPos on both bone kinds; full bit-exactness still requires linking Max's own SDK object code, not a different accumulation order on our side.
VS2008/x87 corpus gate wired for anim, same pattern as §10l's skel gate. pipeline_max_export_anim and anim_builder both build cleanly under the VS2008/Wine reference toolchain (~/build_vs2008_wine_pipeline, §2b) — anim_builder is unconditional under WITH_3D in nel/tools/3d/CMakeLists.txt (not behind the WITH_PIPELINE_NATIVE_OLE OR WITH_LIBGSF gate the pipeline tools sit behind), so it was already part of that build tree's project graph and only needed building. New winebin/pipeline_max_export_anim and winebin/anim_builder wrapper scripts (same translate-absolute-paths-to-Windows-form shape as the existing wrappers) let the native harness drive both under Wine; pipeline_max_anim_corpus_vs2008 (pipeline_max_corpus_test/CMakeLists.txt) runs the identical anim_corpus.py --all --gate-t3 sweep against them, self-skipping (exit 77) without that build tree.
T1/T2 and the direct reference tier hold exactly, as expected. Full ~4.5k-file sweep under VS2008/x87: T1/T2 4452/4452 biped + 71/71 non-biped (byte-identical to x64/SSE — the chunk parser/writer never recomputes a float, only copies stored bytes, so this was never expected to move). Non-biped direct tier (10 files, byte-identity required) stayed 10/10 identical under x87 too — the plain keyframer-track-building path (buildATrack, simple per-key float copies with at most a single scale-matrix multiply) doesn't accumulate enough floating-point operations to diverge between SSE and x87 on these particular files. Biped direct/structural tier unchanged (7 byte-identical + 3166 structural, 833 over the informational tolerance — same worst-delta median 0.1215, same worst-offender list headed by ship_tank_karavan_mort_idle at 4.673, the known keyless-vertical-channel reference-era case from §10d-bis — see §10l for the 2026-07-08 correction to that item's write-up).
New finding: the fauna optimized tier is genuinely cross-build-fragile, and that's a property of anim_builder's own thresholds, not of pipeline_max's decode. Of the 61 fauna files compared through the full export→anim_builder→compare-against-core4_data pipeline, 7 now exceed the reconstructed-animation tolerance under VS2008/x87 where all 61 passed under x64/SSE (§10b's recorded x64 baseline: "1 identical + 60 within tolerance, worst reconstructed delta 0.0017" — i.e. already sitting within ~15% of the 0.002 cutoff on the ORIGINAL build). Two of the seven are outright track-class flips (CTrackDefaultQuat vs CTrackSampledQuat — anim_builder decided a channel was constant-enough to collapse to a single default key, where the reference kept it sampled), the other five are numeric overshoots of 0.0021–0.005 (just over the 0.002 cutoff). Root-caused by direct comparison: the RAW (pre-anim_builder) .anim export for one of the failing files (ju_mo_sapenslaver_idle) already differs between the x64 and VS2008 builds at the byte level (same size, first divergent byte at offset 2962) — i.e. pipeline_max_export_anim's own float arithmetic (the maxScaleValueToNel matrix path, per §10k's Mat3f code) legitimately rounds differently between SSE and x87, same class of divergence as every other FLOATEQ tier in this document (skel/zone/shape), and anim_builder's key-drop/quantization threshold comparisons are sharp enough that a handful of already-borderline corpus files flip sides. This chains two independently-built tools (our exporter, then anim_builder, both now under a third x87 compiler generation relative to whatever produced the ~2004-era core4_data reference) against reference data that was already close to the edge on the very first (x64) measurement — there was never a guarantee that a second x87 build would land closer than SSE did, only that it's a different rounding path.
Gate relaxed accordingly, both builds. anim_corpus.py's optimized-tier comparison is now informational-only (fails["t3info"], not gated) for both biped and non-biped — previously only the biped side had this treatment; the non-biped side was still hard-gated purely because it had never yet been observed to fail. A genuine anim_builder crash/no-output case (t3_opt_crash, distinct from a numeric/class-mismatch t3_opt_fail) stays gated for both kinds — that class of failure is an infrastructure problem (missing cfg, timeout), not float-rounding noise, and gating it is still correct. Re-verified after the change: x64 sweep unchanged (exit 0, identical bucket counts to before), VS2008 sweep exit 0 with the 7 fauna borderline files now reported as informational T3 INFO lines instead of failing the gate. pipeline_max_corpus_test/CMakeLists.txt's comment for both the vs2008 test and the tier's rationale updated to match.
IK blend-transition curve and ease-warp shape — investigated, not progressed, deliberately left as documented. Re-checked both open items from §10c/§10d-bis/§10l against the existing differential datasets (~/biped_anim_dataset2's b_ease_* 6-value sweep and b_ikb_* blend-transition cases) as a candidate "expand the parser" target this session. Confirmed the ease-warp residual is reproducible and matches the previously-recorded scale (component deltas ~0.001–0.009 against ~/biped_anim_dataset2/b_ease_to50's ground truth, growing across the eased segment, consistent with the "≤0.018 on the synthetic sweep" figure already on record) — low corpus impact (~1/60 sampled files key non-default ease) and no new insight found to close it, so left as-is. The IK blend-transition cases (b_ikb_static/b_ikb_out/b_ikb_long/b_ikb_tens0/b_ikb_arm) carry full per-key IK state (pivot arrays, ankle tension, joined-pivot flags) that a real continuous re-solve would need; re-attempting the 2-bone solver that a previous session already tried and reverted (§10c: "regresses the steady cases") without new data or a materially different approach would be repeating a known dead end, so this was not re-attempted. Both remain documented open items, now also catalogued in the wiki's chunk-format reference (below).
max_geometry_formats.md gained Part J. The Biped (0x9155) system-object chunk map — structural (figure-pose) records, animation keytrack chunk pairs, the two independent sidedness conventions (storage order = right-first; value convention = fixed-basis-with-mirror, except feet/hands which are absolute per side), and the open items above — was condensed out of this document's §10c/§10e/§10d-bis/§10l into that wiki page's format-reference style (matching its existing Parts A–I), so a reader wanting "what are the bytes" doesn't have to reconstruct it from this document's session-by-session narrative. This document remains the narrative/derivation log and governs precedence per that page's own §0a rule.
ship_tank_karavan_mort_idle follow-up — a real lead, a falsified fix, corrected write-up (2026-07-08)Prompted by a user question, the §10/§10l "keyless-vertical, genuinely irreproducible" write-up for this one file got a second look, and the framing was wrong on two counts.
First correction (already landed above): "computed live, therefore irreproducible" is a non-sequitur. If Character Studio derives a value from a deterministic computation, that computation is reverse-engineerable in principle — the same category as the IK-blend-transition item, not a different one. Corrected the §10/§10d-bis/§10l wording from "genuinely irreproducible" to "not yet reproduced."
**Second, a concrete lead: mort_idle's companion mort (the actual death-transition clip, not the idle loop) has REAL position keys, and its own reference .anim shows the COM sinking from the standing figure height (6.0) down to ~1.275 over 195 keys with a small damped oscillation at the end (values 1.2790→1.2747→1.2748→1.2750, converging) — i.e. this is an ordinary keyed animation of a large Karavan ship/tank unit collapsing straight down (x/y unchanged) into a settled "dead" pose, nothing exotic. Dumping this file's own raw Biped chunks (pipeline_max_export_skel --dump-rig) turned up chunk 0x0104 (documented in Part J as the "base figure-mode COM world matrix," previously used only as a fallback when 0x006c is absent) holding translation 1.27498329 — matching mort's own converged settle value to 3e-9, and much closer to mort_idle's reference height (1.3271889686584473, off by ~0.052) than the figure/standing height our code was actually falling back to (6.0, off by 4.673 — exactly the corpus's recorded worst-delta number).
The fix that looked obvious was falsified by counter-example within the same session. Prototyped: read 0x0104 unconditionally (not only as the 0x006c-absent fallback) into a new SBipedRig::BaseFramePos, and use it in place of the figure-walk COM height specifically when a rig's whole vertical keytrack is empty. Fixed ship_tank_karavan_mort_idle (worst delta 4.673 → ~0.05). But an env-gated corpus-wide scan (PMB_ANIM_DUMP_VERTFALLBACK, kept in the tree) found 82 biped anim files hit this same "zero COM-vertical keys" condition, not one — the earlier "SINGLE corpus file" claim in §10 was itself wrong, just never corpus-swept before now. For the overwhelming majority the figure height and the 0x0104 translation coincide almost exactly (a no-op either way), but checking the handful where they don't against direct references gave a split verdict:
| File(s) | figure height (old) | 0x0104 translation (tried) | Reference | Winner |
|---|---|---|---|---|
ship_tank_karavan_mort_idle |
6.0 | 1.27498329 | 1.3271889686584473 | 0x0104 (by far) |
fy_hof_first_decoupe_end/_loop |
0.951309025 | 0.913986206 | 0.9139862060546875 | 0x0104 (exact) |
fy_hom_first_recruteur_end/_init/_loop |
0.94373399 | 0.455861151 | 0.9437339901924133 | figure height (exact) |
fy_hom_first_meca_end/_init |
0.94373399 | 0.95581907 | 0.9437339901924133 | figure height (exact) |
Neither source is uniformly right — "always prefer 0x0104" fixes ship_tank/decoupe and breaks recruteur (by 0.49, a much larger error than the one it fixes on decoupe) and meca (by 0.012). No discriminator tried (rig gender/character, magnitude of disagreement, which task-set the clip belongs to) separates the two winning groups from a sample this small. Per §12.2 ("type only what you can prove"), the change was reverted — full corpus re-verified byte-identical to the pre-attempt baseline (833 over-tol, worst 4.673, T1/T2 unchanged). The diagnostic (PMB_ANIM_DUMP_VERTFALLBACK env var, one line in CBipedAnimEval's constructor) and the harmless plumbing that populates BaseFramePos/HaveBaseFramePos on SBipedRig (read-only, zero behavior change, nothing else consults it) were kept so a future session doesn't have to rediscover the 82-file list or re-derive the chunk from scratch — see biped_rig.h's field comment.
Status: genuinely open, now with a real dataset to chase (82 files, ~5 with a resolvable direct reference and a known disagreement direction) instead of a single unexplained outlier. Whatever decides which of the two figure-adjacent sources (figure height vs. 0x0104) Character Studio actually used is likely a third stored bit (a dirty/current-vs-committed flag, or which of the two was more recently "committed" via a figure-mode re-entry) rather than a property of either value in isolation — worth a live-Max differential probe (create a biped, move it in figure mode, exit, re-enter and move it again without committing, save, dump both chunks) rather than more corpus mining, same conclusion as the prs:name marker item in §10l. That's the recruteur/meca/decoupe puzzle — static first-person poses with no motion involved, a bookkeeping question about which of two nearly-equal snapshots wins.
Third correction, same day, on ship_tank specifically — walked back the "continuing physics settle" framing above. mort's tail (dumped in full when checked) is a real fall–bounce–settle shape (drops from ~4.85, bottoms at ~1.21, rebounds to a ~1.30 peak, decays toward ~1.275 by the last key) — but a curve shaped like a damped oscillation does not, by itself, tell you whether it came from an actual dynamics/momentum computation or from an animator hand-placing an overshoot key in Character Studio's ordinary TCB keyframer; both look identical once biped's oversampling (§10c, one sample per frame regardless of source) turns them into a dense keyframe track. Prompted by a second look, this is plausibly just a hand-animated fall (the described "cartoonish"/exaggerated bounce reads as authored, not tuned physics) — in which case there is no hidden velocity/momentum state to appeal to, and mort_idle's reference height (1.327) sitting ~5cm above where mort's stored keys AND mort_idle.max's own 0x0104 snapshot both stop (1.275) is more plausibly a plain authoring gap between two separately-touched files (someone set the idle pose's resting height slightly differently than the death clip actually ends, and nothing reconciled the two) than a computed continuation of anything. Filed as the same class of problem as the recruteur/meca/decoupe puzzle above and the prs:name markers: a per-file human judgment call, not something with a decodable formula behind it. The "missing velocity term" story from the first pass of this write-up should be read as withdrawn, not as a standing hypothesis.
Fourth, separating "why is our export wrong" from "why is the reference itself 5cm off": the former has a confirmed root cause, the latter still doesn't. A follow-up question distinguished these. Root cause of our 4.673 error, confirmed via a new diagnostic (PMB_ANIM_DUMP_VHT, dumps the COM node's TM-controller chunks — kept in the tree): our fallback for a zero-vertical-keys rig reads chunk 0x006c's [4..6], which for THIS file holds (0, 6, 1.279) Y-up — the rig's generic/template standing height (6.0), correctly used elsewhere for skeleton bind-pose export but wrong here. A third independent chunk, 0x0260 (a live-state "snapshot" sharing 0x006c's exact 48-byte/12-float layout but holding different values — part of the same 0x0258-0x0261 shadow-bank family noted in Part J), has at its own [4..6] the value (0.6086303, 1.27498329, 1.98431742) Y-up — converts to our exact x/y plus the same 1.275 already found twice (mort's last key, chunk 0x0104). Three independent sources now agree the file's actual current position is ~1.275, not 6.0 — the bug (using the wrong chunk / wrong semantic of "position") is solid, confirmed, not speculative. What's not solid: attempting to combine 0x0260's other fields ([0..2] as a V/H/T-style displacement, [8..11] as a rotation quat, mirroring 0x006c's own layout and the parseComRecord formula) to see if it resolves the residual 5cm gap to the actual reference (1.327) produces nonsense (a wildly wrong position, not 1.327) — so 0x0260 corroborates where the bug's wrong fallback should have pointed, but doesn't explain the separate, smaller authoring-gap question. And per the falsified-fix finding above, even confirming the bug's mechanism doesn't yet yield a safe fix, since "prefer the current-position chunk" still breaks recruteur/meca.
Fifth: a live-Max probe is now in flight, with a real SDK-confirmed candidate discriminator. gen_biped_vhtsrc_probe.ms (Max-side, nel/tools/3d/pipeline_max_corpus_test/) was written for the "worth a live-Max differential probe" step above: Part A loads the actual disagreement/agreement files straight from R:\graphics and dumps figure-mode state, controller key counts, and a COM-height sweep across each file's frame range (read-only, never re-saved, also attempts a live NelExportAnimation re-export for an authoritative today's-answer where the plugin is available); Part B reproduces each candidate cause (ordinary move, figure-mode move, structural height edit, timeline left scrubbed, uniform scale) on a fresh synthetic biped. Cross-checking against the Character Studio MAXScript/SDK reference surfaced a concrete, documented candidate that hadn't been considered: <biped_ctrl>.dynamicsType — 0 = "Biped Dynamics" (Character Studio keeps live-computing airborne trajectories and balance, via per-key Balance Factor/Dynamics Blend, even off-key) vs 1 = "Spline Dynamics" (plain spline interpolation over whatever keys exist, no live physics). If this differs between the FIG-WINS files and the BASE-WINS files, it's the discriminator; the probe script explicitly logs it per real file and A/B-tests it directly (two synthetic cases identical except for this one flag). Not yet run; this reopens the "continuing physics" possibility on firmer ground than the curve-shape argument that was withdrawn above — this time from a documented API property, not an inference from a curve's shape.
Sixth: the probe ran, dynamicsType is dead, and a follow-up horizontal-channel audit reverses the recommendation — figure height is the right general default after all. Results (~/biped_vhtsrc_probe/manifest.txt, Max 9):
dynamicsType is 0 ("Biped Dynamics") on every single real corpus file checked — recruteur, meca, decoupe, ship_tank, everything, no variation at all. And the direct synthetic A/B test (b6/b7: identical move, only dynamicsType flipped 0→1) landed at the identical evaluated COM position both ways. Doubly falsified — not the discriminator.b1 ordinary move vs b2 figure-mode move) pinned the actual mechanism cleanly: an ordinary Move outside Figure Mode updates chunk 0x0104/0x0260 ("current position") but leaves 0x006c ("figure height") untouched; a Figure Mode move updates both together. This is exactly the divergence signature seen in the corpus — confirmed mechanistically, not just observed.recruteur/meca/couture/decoupe showed Max's live value matches our baseFramePos (0x0104/0x0260) reading exactly in every case — including where that disagrees with the reference. First read as proof that 0x0104 is what the real exporter should use and the old references are simply stale (a "reference-era mismatch," same category as _big/_small). This reasoning turned out to be close to tautological: 0x0104/0x0260 exists precisely because Character Studio caches the current live transform there, so of course Max's live read matches its own cache — that's not independent evidence about what the exporter historically consumed, only about what's currently sitting in the viewport.PMB_ANIM_DUMP_HORZFALLBACK diagnostic, corpus-wide: 108 hits, up from the initial 82 vertical-only count once multi-biped files are counted per-rig instead of conflated — see below). The result reverses the earlier conclusion: figure position wins horizontally in every checkable case, including recruteur itself (all 3 variants, exact match) and upwards of 30 otherwise-unrelated fy_hof_first_* clips (different tasks, different characters) that all carry the same ~5.3mm baseFramePos.y offset from figure position — a constant baked into the whole female template rig's current-position snapshot, present in every clip derived from it, evidently always harmless because the real exporter has never read it. fy_hof_first_decoupe_end's horizontal case is more ambiguous (X favors base, Y is near-zero either way, inconclusively).ship_tank_karavan_mort_idle and fy_hof_first_decoupe_end/_loop remain genuine, still-unexplained exceptions where current position matches the reference better — now understood as narrow, file-specific anomalies rather than evidence for a blanket rule change. Chasing them further needs a per-file explanation (what's different about these three), not another attempt at a global source-preference rule.gen_biped_vhtsrc_probe.ms's findComNode picked the first biped-like node found in the scene, silently conflating tr_mo_kitin_queen's two bipeds (Bip01 + a nested Bip02) into one comparison. Fixed to findComNodes (enumerates and dumps every COM node in the file separately, labeled by name) before drawing any conclusion from that file's data — the corrected per-file, per-rig scan is what surfaced the 108 (vs. 82) horizontal-fallback count above. Confirms the general lesson: any multi-biped file needs this per-rig discipline, not just kitin_queen.Seventh: the evidence tally, stated plainly, and why "no discriminator found" isn't the same conclusion as "neither source is correct." Prompted by a direct challenge ("you've been flipping between these options — do we have evidence from both sides?"), the accumulated evidence was tallied rather than reacted to point-by-point: figure height matches the reference exactly in 10+ independent cases (recruteur ×3 both channels, meca ×2, couture, coup_1stperson ×3, and ~30 unrelated fy_hof_first_* clips horizontally) across a wide, diverse sample; current position matches exactly in essentially one independent case (decoupe — _end/_loop are the same character/task, not two data points, and even decoupe's own horizontal Y leans toward figure instead) plus ship_tank, which isn't an exact match for either source (base is merely less wrong, still 5cm off). This is not "two competing hypotheses, evidence 50/50" — figure height is the well-supported general default (matching what's already shipped), and ship_tank/decoupe are two separate real anomalies needing their own explanation. A follow-up sharpened this further: ship_tank's value isn't a "we haven't found the right stored chunk yet" situation — an exhaustive byte scan already showed the reference value (1.327) isn't stored as a literal float32 anywhere in the file, at any offset. So ship_tank and decoupe fail differently: one has two real, mutually-exclusive, per-file-correct chunks with no byte-level discriminator between them and no live-Max evidence resolving it (see below); the other doesn't match either identified chunk at all and isn't explained by a literal stored value either.
Eighth: reframing "we can only read what's currently stored" — that's not a limitation, it's the whole point. A correction from the same conversation: earlier reasoning leaned on "a script can't recover what an artist did in the past" as grounds to stop investigating. That's backwards — export is deterministic on current file bytes; whatever function the real exporter applies to get from stored chunks to output height is fully determined by those bytes today, regardless of any file's edit history. The actual blocker isn't unknowability, it's that the straightforward candidates (figure height, current-position chunk, a literal byte-scan for the reference value, dynamicsType) have all been tried for ship_tank and none explain it — concretely still unchecked: the value as a stored double (8 bytes, never scanned for, only float32 was), several still-unmapped 0x9155 chunks (0x0100, 0x0107, 0x0108, 0x0190, 0x01f4, 0x01f9, 0x00c8–0x00ce, 0x015e) for a value that combines with the known 1.275 to produce 1.327, and GetTalentFigMode/SetTalentFigMode (a real, distinct SDK-documented mode, never checked).
Ninth: the actual differential-dataset methodology, applied broadly instead of one hypothesis at a time (per Kaetemi's direction). Rather than continuing to test single guesses against ship_tank specifically, the session shifted to this project's own established decode methodology (§10's "Method note": controlled variants off a real rig, ground truth, per-chunk diff against baseline, verify generalization) — applied broadly to the open COM-source question rather than narrowly re-testing one theory:
pipeline_max_export_anim --diff-rig <A.max> <B.max> <out.txt> — an unbiased, full per-chunk byte/float diff of every Biped (0x9155) system object between two files (multi-biped-aware, indexed per object). No assumption about which chunk matters; every chunk that changed is reported, with bit-pattern-exact comparison (not float ==, which would miss a +0.0/-0.0 flip — caught exactly this on one synthetic case, see below). Validated immediately against the existing ~/biped_vhtsrc_probe synthetic files (b0–b7) and found real, previously-unrecorded facts on the spot:
0x0065, 0x0104, 0x0259, 0x0260 — a "current position" shadow-copy family, confirmed larger than previously catalogued. 0x006c (figure height) is untouched.0x006c together, plus a small height-derived 0x00ca[0] recompute (consistent with that chunk holding something gravAccel-adjacent, since gravAccel's documented default scales with height).0x0012 is very likely the literal storage location of dynamicsType — a clean 0→1 flip on the b7 (dynamicsType=1) case, nothing else. A newly-confirmed chunk mapping, even though the property itself turned out not to matter for this investigation.0x000b, 0x000c, 0x000d, 0x000f, 0x0010, 0x0069, 0x006a, 0x01f4, 0x01f5, 0x01f7, 0x01f9, 0x01fa, ...) — expected, confirms these are absolute-length-type fields that scale with structural height, not a contradiction of §10l's separate finding that 0x01f4/0x01f9's own values aren't close to 1.0 (that finding was about whether they are a multiplier; this is about whether they change under a height edit — they do, because they're lengths).scale produces zero byte-level change anywhere on the system object — confirms Biped locks out ordinary scale operations on the COM entirely, not just that it doesn't happen to move the position.0x0259 from +0.0 to -0.0 — a sign-of-zero no-op, not a real signal. A separate tiny chunk 0x0709 (12 bytes) changes unpredictably across nearly every case with denormalized-looking float noise, matching the "uninitialized memory" class already documented for other exporters in this project (§10g) rather than anything meaningful.gen_biped_skel_animode_probe.ms — loads a REAL skeleton file (fy_hom_skel.max, not a synthetic biped.createNew rig and not one of the animation files under investigation), commits Figure Mode into Animation Mode as the baseline (matching how the real corpus files' current-state history presumably began), then produces 14 single-variable cases (ordinary move, figure-mode move, dynamicsType, talentFigMode, lockCom, moveAllMode, arm-key-plus-scrub, key-then-delete, copy/paste posture, footstep-mode round-trip, mirror, clear-all-animation, scale, height edit) and 8 combined-toggle cases (per the direction that the real answer might be a combination of switches, not a single one) — every case from an independent fresh reload of the skeleton file, specifically because Figure/Animation Mode switching is known to misbehave in Character Studio and a bad transition must not be allowed to cascade into other cases.Tenth: the probe ran (22 cases), and combined with a live-Max observation, this fully explains the mechanism behind the whole ship_tank/decoupe-vs-recruteur/meca split — not just empirically, but causally. --diff-rig against b00_baseline for every case:
0x0065, 0x0104, 0x0259, 0x0260 move together on any ordinary move (0x006c untouched); a figure-mode move updates all four of those plus 0x006c together — same shape as the earlier synthetic-biped result, now confirmed on a real corpus-shaped skeleton too.0x0012 is very likely dynamicsType's literal storage byte — an isolated, clean 0→1 flip, nothing else changes. A genuine new chunk identification, even though the property itself doesn't matter here.talentFigMode/lockCom are global Character Studio session preferences, not per-file data — they read back as set (the MAXScript assignment worked) but produced zero file changes, and lockCom was observed true in a case that never touched it (carried over from a prior case in the same Max session, since resetMaxFile doesn't reset them). Ruled out conclusively, and for a more interesting reason than "no effect" — they're never saved to the .max at all.key-then-delete case (b08/c04) was a bug in the test script, not a finding: biped.setTransform com #pos ... true keys both the horizontal and vertical sub-controllers, but the atom only captured and deleted the vertical one's key — the resulting ground truth shows numKeys.horizontal = 1, i.e. a real, live, un-deleted key, not a "ghost" residue of a properly-deleted one. The 0x012c/0x012d size growth is exactly that live key's storage, nothing more subtle.0x006c-vs-current-position-family split. Negative but real: the mechanism isn't dynamicsType, talentFigMode, lockCom, footstep-conversion-without-motion, clearAllAnimation (only touches one shadow-bank chunk, 0x025e), or any combination of these with a move.The mechanism, closed by a live-Max observation (Kaetemi, interactively, on c08_move_lockcom.max): opening a file with the divergence (figure height ≠ current position) and merely toggling INTO Figure Mode snaps the displayed pose up to the figure-height value — expected, since Figure Mode always displays the figure state. But toggling back OUT of Figure Mode again does not restore the pre-toggle current position — the skeleton stays at the figure-height pose. So exiting Figure Mode unconditionally commits the figure value as the new current-position state, even when nothing was deliberately edited while in figure mode. This means the divergence between 0x006c and the current-position family isn't a stable, load-bearing property of a file — it's fragile, and gets silently healed the instant anyone, for any reason (checking proportions, an unrelated inspection pass) enters and exits Figure Mode after the divergence was introduced. That single mechanism accounts for both directions of the split with no separate rule needed: recruteur/meca/couture got healed at some point after whatever ordinary move first diverged them (matching the reference exactly, since healing restores the intended figure pose); ship_tank/decoupe never got that incidental heal, so their divergence survived into what became the reference. This is why no byte-level discriminator was ever going to be found — the deciding fact ("did someone open this file and nudge into figure mode afterward, for whatever reason") isn't recorded anywhere in the format, by construction, not by an oversight in the search. It does not, however, explain ship_tank's own residual: even its "un-healed" current position (1.275) is still 5cm short of the actual reference (1.327), so that file's specific gap remains open on its own separate terms.
Given this, the earlier "leave the fallback as figure-height, document ship_tank/decoupe as open anomalies" conclusion stands, now with a real causal story behind it rather than an unresolved empirical split.
Eleventh, ship_tank's own residual — decoded, not computed. A sequence of interactive Max 9 tests (Kaetemi, live) methodically ruled out every "live computation" story instead of confirming one, which is what eventually pointed at the right answer:
Vertical as a genuinely dashed (unkeyed) line, confirming the chunk-level "zero vertical keys" read is correct (a possibly-missed key seen in Character Studio's "Animation Workbench" panel turned out to be showing Bip01 Pelvis, a different bone, not Bip01's own Vertical sub-anim).0, -0, 6 (0x006c); Animation Mode reads exactly 0.609, -1.984, 1.327 — the actual reference value — matching neither 0x006c nor the stored 0x0104-family current position (1.275).Bip01 outside Figure Mode (confirmed via --diff-rig to change the stored 0x0065/0x0104/0x0259/0x0260 current-position chunks) and then toggling Figure Mode still converges back to exactly 0.609, -1.984, 1.327 — ruling out a simple chunk read.Turning's only real key (the rig's one piece of keyed motion) changed nothing at all — Animation Mode still read exactly the same value afterward. This falsified the leading hypothesis at the time (a live "Biped Dynamics" balance/airborne solve consuming the keyed rotation as input) outright: if the rotation were a real input, removing it should have changed the result.0.609, -1.984, 1.327 every time — the ordinary behavior of a keyframe controller's own stored default value, not a sign of any live computation at all.That reframed the whole search: not "what does Character Studio compute," but "what stored constant are we not reading yet." A fresh whole-file byte scan (this time checking both float32 and double64, at every offset, not just the previously-decoded chunks) still found nothing — because the value isn't stored directly at all. The actual answer: 0x0260[1] is a height-correction scalar. 0x006c and 0x0260 share the same 12-float record layout (§10, §10n "Fourth"); 0x006c[1] is always exactly 0 (figure mode needs no correction), while 0x0260[1] holds 0.0522057191 for this file — and BaseFramePos.z + 0x0260[1] (1.27498329 + 0.0522057191 = 1.3271890091) reproduces the shipped reference (1.3271889686584473) to 4×10⁻⁸, essentially exact float32 noise. Verified consistent on two more files: 0x0260[1] reads exactly 0 on fy_hof_first_decoupe_end/_init (where BaseFramePos alone was already an exact match — correctly needing no correction) and exactly 0 on fy_hom_first_recruteur_end too (where the correction is simply irrelevant, since that file needs the figure-height branch entirely, not base-plus-correction). 0x0065/0x0259 turned out not to be independent data at all — they're 0x0104's exact 16-float matrix with a 4-float header prepended, which is why they always tracked 0x0104 in lockstep; the real, distinct field was 0x0260[1] all along, previously overlooked because earlier dumps of that chunk were read as ComDisp-style rotate-then-add candidates (the same formula as 0x006c[0..2]) rather than checked as a plain additive scalar on its own.
HeightCorrection/HaveHeightCorrection are now typed fields on SBipedRig (biped_rig.h/.cpp), populated from 0x0260[1]. Not yet substituted into ComPos — the "which branch, figure height or base(+correction)" ambiguity between files is unchanged by this finding (it explains why the base branch is now exact when it's the right branch, not which files should use it), so this stays documented, provable plumbing rather than a shipped behavior change, consistent with every other still-open item in this investigation.
Twelfth: dynamicsType's only real corpus signal, for the record. The corrected corpus-wide scan (§10n "Tenth" caught and fixed a read bug — floatBitsAsUint was used instead of a plain float read, misreporting every 1.0 as 1065353216) found dynamicsType == 1 on exactly 64 files, uniformly 0 on the rest — including every target file in this whole investigation (recruteur, meca, couture, decoupe ×3, ship_tank ×2 all read 0), so it plays no role in any of the FIG-WINS/BASE-WINS/live-solve findings above. But the 64-file set is a clean, sensible category in its own right: almost entirely strafe animations (fy_hom/fy_hof_*_strafe_droite/gauche and the co_*/l2m/ab/ad/fu/fus/pa task variants of the same), plus walk_to_idle, emote_drunk, and ca_hom_*marche_arriere (walk backward) — simple lateral/reverse locomotion where an artist choosing plain spline interpolation over Character Studio's live balance computation makes sense. Good independent confirmation that 0x0012 really is dynamicsType (a meaningful, corpus-consistent category, not noise), even though it isn't the discriminator this investigation was chasing. DynamicsType/HaveDynamicsType are now typed fields on SBipedRig (biped_rig.h/.cpp), populated but not consumed by any decision.
Closing SUPERSEDED 2026-07-08 (§10o) — this was wrong. There is no live dynamics solve: the unkeyed COM height is a plain stored value in the biped's current-position frame, and ship_tank's residual as: mechanism identified and evidenced (a live Biped Dynamics balance solve), not bit-exactly reproducible in any reasonable-effort sense, same disposition as the IK-blend-transition class.ship_tank_karavan_mort_idle now exports byte-exact. The whole "FIG-WINS vs BASE-WINS is an irrecoverable per-file human-judgment quirk" framing of §10n collapses into one decodable rule; see below.
ship_tank resolved, Move All (0x0117) decoded (2026-07-08)A live-Max-9 differential session (Kaetemi authoring probe files interactively, the headless side decoding them with pipeline_max_export_anim --diff-rig/--dump-rig) closed the entire §10n investigation. The key realisations, each backed by a probe rather than inference:
The unkeyed COM value is NOT derived from figure height — it is a stored value in the current-position frame. Three live observations on ship_tank_karavan_mort_idle (whose Horizontal + Turning are keyed but Vertical is unkeyed, sitting at NeL 1.327 in animation mode vs 0,0,6 in figure mode) pin this beyond doubt:
1.327. So the exported (animation-mode) value cannot be figure-derived.1.327 — the --diff-rig shows only the keytrack chunks (0x012c/d) shrink; the current-position family (0x0104/0x0260/0x0065/0x0259) is untouched. The baseline is not in the curves.0,0,6 — and --diff-rig shows exactly those current-position chunks being overwritten with the figure value (0x0104[13]/0x0260[5]: 1.275 → 6; 0x0260[1] HeightCorrection: 0.052 → 0). Clear-All is literally the operation that writes figure into the current-position frame; nothing else does. This is the smoking gun that the current-position family is the animation baseline.So the unkeyed COM = the current-position frame, concretely 0x0104[13] / 0x0260[5] (the "up" translation, 1.27498329) + 0x0260[1] HeightCorrection (0.0522) = 1.3271889…, reproducing the shipped reference to float noise. §10n "Eleventh" had already found the 0x0104 + 0x0260[1] arithmetic but left it as unconsumed plumbing under the false belief that the figure-vs-base branch was an irrecoverable human-judgment call; it is not.
Move All Mode = chunk 0x0117, a newly proven, fully validated decode. Three interactive Move All probes on the same file, each diffed against the unmodified baseline, pin the layout exactly: +10z → [13]=10; x5/y10/z20 → [12]=5, [13]=20, [14]=-10; rotZ45 → the upper-left 3×3 becomes a 45° rotation plus induced translation. So 0x0117 is the Move All reference-frame transform, a Y-up row-major affine 4×4 (16 floats): translation row [12,13,14] = (x, z_up, -y) → NeL (m12, -m14, m13), rotation in the 3×3. It is identity on every shipped corpus file (Move All was never used in the reference assets), so applying it is a no-op there — but it is a real world-space offset the exporter now applies to the final COM (the +10z probe correctly exports z=11.327, the x5/y10/z20 probe (5.609, 8.016, 21.327)). Decoded onto SBipedRig::MoveAllTrans/HaveMoveAll (biped_rig.h/.cpp).
The base-vs-figure discriminator: 0x006c[0..2] (the figure-mode ComDisp). Nonzero ⟺ the COM holds a committed non-figure pose, so the current-position vertical is authoritative (mort_idle → 1.327, decoupe → 0.914); exactly zero ⟺ the COM sits at figure and the snapshot is stale relative to the shipped reference (recruteur/meca → figure, the reference-era class — the .max was re-posed after the reference was exported, max-authoritative per §9). This is the one in-file signal that splits all four reference files cleanly, decoded onto SBipedRig::ComDispNonZero. Honest status: empirically airtight (matches all four references, zero corpus regressions) but reads as an "is the current position committed/valid" proxy whose physical mechanism isn't fully proven; the competing reading is that the figure-wins references are simply stale and "always current-position" is the truer decode (which would diverge from ~30+ reference files). The discriminator was chosen because it costs nothing and the shipped references are ground truth — see §12.2.
Applied to the VERTICAL AXIS ONLY. The current-position frame's horizontal forward component is a small (~5 mm) baked template offset the reference exporter does not read (decoupe REF horizontal y=0 vs the frame's -0.0053; this is the §10n "Sixth" fy_hof ~5.3 mm offset). So the substitution is comPos.z only; horizontal stays at figure/keys (which for decoupe lands ~0.1 mm from the reference, better than the current-position frame would). biped_anim.cpp's COM branch; A/B via PMB_ANIM_FIGURE_COM (forces the old figure-height fallback). Horizontal current-position handling stays an open item (unproven), consistent with "type only what you can prove."
Corpus T3 after this round (full 4524-source sweep, biped direct-ref tier): ship_tank_karavan_mort_idle 6.0 → 1.32718901, byte-exact against the reference (84/84 keys); worst biped key-delta 4.673 → 1.279 (the new worst is a co_* IK-interval file, unrelated to the COM baseline), files over the informational tolerance 833 → 832 — a per-file A/B over all 3173 direct-ref biped files confirms the only file that changed is mort_idle; nothing regressed. Direct-ref byte-identity tiers unchanged (biped 7/7, non-biped 10/10). New diagnostics kept in the tree: pipeline_max_export_anim --dump-rig <file> (single-file 0x9155 chunk dump) and anim_corpus.py's PMB_ANIM_DELTALOG (per-file worst-delta CSV for A/B regression diffing).
Coverage-frontier field probe staged (2026-07-08). With the COM-baseline question closed, the next decode target is raw coverage of the still-unread chunks — biped --dump-rig over the anim corpus (biped_coverage.py) puts decode at ~38% of distinct 0x9155 chunk ids / ~81% by byte weight, the remainder dominated by 0x014d (the single largest, ~398 KB), the 0x025d/0x025e shadow bank, the 0x01f4..0x01fd structural tables, 0x0110/0x0116/0x0118, 0x0201/0x0202, 0x00c8..0x00ce, 0x015e, and 0x0107/0x0108/0x0190. gen_biped_fields_probe.ms (Max 9) extends §10n "Ninth"'s differential methodology to those: it loads a real skeleton and produces ~55 single-variable cases sweeping the fields gen_biped_skel_animode_probe.ms did not touch — structural figure params (height, ankleAttach, link counts, fingers/toes, triangle pelvis/neck, arms/foreFeet/shortThumb/knuckles, bodyType, rootName), per-limb figure poses, per-track animation keys, key TCB/ease params, IK planted/sliding/free keys, gravAccel, separate-tracks/euler/twist toggles, adaptation locks and in-place mode — each a fresh-reload single change so --diff-rig f00_baseline fNN pins the owning chunk. Companion gen_biped_fields_diff.py batch-diffs every case against the baseline and emits a field→chunk map flagging which changed chunks are still undecoded (against biped_coverage.py's DECODED set). Ran 2026-07-08 — results in §10p.
gen_biped_fields_probe.ms ran in Max 9 off fy_hom_skel.max (65 files: baseline + 64 single-variable cases; ~/biped_fields_probe/, FIELD_CHUNK_MAP.txt is the analyzer output). Every case that changed anything isolated cleanly to one field's chunks. Findings, each proven by the single-variable --diff-rig plus content inspection of the relevant chunk:
The 0x0258–0x0261 shadow bank IS the 0x0064–0x006d pose-record family mirrored at a fixed +0x01F4 offset (0x0258−0x0064 = 0x01F4). Every base record has its +0x1F4 twin at the identical byte size (48/80/28/40/880/880/0/48/40), and each figure-pose edit touches its base record AND that twin: f38_fig_head → 0x0064+0x0258, f39_fig_neck → 0x0065+0x0259, f40_fig_pelvis → 0x0066+0x025a, f37_fig_spine → 0x0067+0x025b, f06_tailLinks → 0x0068+0x025c, f33/34/36 leg → 0x0069+0x025d, f30/31/32/41 arm → 0x006a+0x025e, and the COM pair 0x006c+0x0260 (§10o). Content confirms it: the static records (head/neck/pelvis/spine/tail/pony) are byte-identical between base and twin; the leg/arm records (0x0069/0x025d, 0x006a/0x025e) differ only at ULP level (a recomputed live copy, not a literal memcpy); and the sole genuinely-divergent member is 0x006c/0x0260 — the figure-vs-current COM, already decoded in §10o. So the entire shadow bank is the animation-mode live-state mirror of the figure pose records: same layout, decode-by-mirror, nothing new needed on the export side (it already rides through raw, T1/T2 green). This retires the "0x025d/0x025e shadow bank" from the top-unknowns list — it was never independent data.
The 0x01f4..0x01fc per-limb length tables, attributed to limbs. f01_height moves 0x01f4/f5/f7/f9/fa/fc (they scale with structural height — absolute lengths, confirming §10n "Tenth"), and each per-limb link-count case pins which table is which: 0x01f5=neck (f04_neckLinks), 0x01f7=spine (f03_spineLinks), 0x01f8=tail (f06_tailLinks), 0x01f9=leg/toe (f05_legLinks4/f08_toeLinks/f10_toes), 0x01fa=arm/finger (f07_fingerLinks/f09_fingers/f14_arms), 0x01f4/0x01fc=global (height-only). This is §10's open-item-1 candidate location, now attributed per limb — the suspected per-limb figure-scale source for the sub-mm chain-position residues.
Literal-field decodes (single-chunk, content-verified):
0x00ca[4] = gravAccel (edit 9.916→500 changed only that float). The record is {?, height, height/2, ?, gravAccel} — [1]=1.7892=height; height/ankleAttach/gravAccel all touch it because it's a height-derived dynamics-param record (the §10o "Tenth" gravAccel-adjacent chunk).0x0115 = bodyType (edit 3→1 changed 0x0115 from int 3 to int 1, nothing else) — this reframes the "FigureVersion" marker of §10/§10e (see the caveat below).0x0102 = the sub-anim index/enable table — header {count=16, id-list…}; grows by one entry (two dwords) whenever any keytrack gains keys (f50_key_com_pos and every fNN_key_*/IK case). Matches §10d-bis Round-3's "0x0102 decodes as a sub-anim index table."0x0110 = a per-track anim-handle/id table — dwords stepping by 11 in two runs (613,624,635,646 / 851,862,873,884); renumbers up when any link count changes. Internal biped anim-handle bookkeeping, not geometry.0x015e = the track-separation flags (9-dword array; f90/91/92/93 sep-arms/legs/spine/neck each flip an entry; tail/pony didn't take on this rig).0x014b = inPlaceMode; 0x0109 = figureMode=false commit — moves exactly 0x0109 1→0 plus the §10n current-position/shadow-bank caches; 1 on skeleton sources, 0 on every corpus animation file. The twist probe case that originally flagged this chunk toggled modes as a side effect); 0x0204 = a link-count-dependent scalar (73); 0x001a/0x000a = finger/toe and tail/arm structure presence records.0x014d is empty (8 bytes, {0,0}) on a static skeleton — none of the 64 figure/structure/pose/single-key cases touched it, and on this rig it carries no data at all. Its ~398 KB bulk on the corpus is therefore animation/display payload, not figure or structure state; cracking it needs an animation-heavy probe (dense multi-frame keys, or a direct diff of two real anim files), not this skeleton-based sweep. Recorded as the remaining single largest unknown, now with its category narrowed.
Resolved — 0x0115 = bodyType (0=Skeleton, 1=Male, 2=Female, 3=Classic), and the "FigureVersion" gate's real semantic is authoring era via body-type default (2026-07-08). §10/§10e read 0x0115 as a figure-format version stamp (int 3 on 168/169 corpus rigs, int 0 on the fresh Max 9 dataset + kitin_queen) and gate the fresh-format decode rules on it being 0. The probe proved 0x0115 literally stores the biped bodyType enum (the 3→1 edit wrote exactly 1), and the MAXScript reference pins the values: 0=Skeleton (the documented createNew default), 1=Male, 2=Female, 3=Classic. So the legacy corpus is uniformly Classic — the cube-parts body, the Character-Studio-1.x-era look the Max 3 rigs were authored with (and why the per-part Biped Objects store nothing: a Classic part is a box derived from link length + a stored cross-section, see max_geometry_formats Part J) — while every fresh biped.createNew rig (the differential datasets, the manually-rebuilt kitin_queen) carries the Skeleton default 0. The gate is unchanged and still correct; its meaning is "authored-as-Classic (legacy) vs left-at-Skeleton-default (fresh)", not a format version. Code reconciled: SBipedRig::FigureVersion renamed BodyType (pure rename, skel corpus gate re-verified bit-identical).
Negative/confirmatory results (no new signal, but they close loops): talentFigMode/lockCom/adaptLockHorz|Vert|Turn/rootName/ponytail/triangleNeck/foreFeet/shortThumb/knuckles produced no 0x9155 change — the first three are the global session prefs already established in §10o (never saved to the file); the rest read back ? on this rig (property absent on this build) or aren't stored on the system object. Probe limitations (not findings, so not decodes): the COM sub-channel addNewKey cases (f51/52/53 vertical/horizontal/turn isolation, and the f70..f74 TCB-param cases that depend on them) didn't take — Character Studio's biped V/H/T sub-controllers reject addNewKey; the f50_key_com_pos biped.setTransform path did work (hit 0x012c/d+0x0130/1), so COM keying is covered, just not per-channel-isolated; setEulerActive/setTwist guesses landed partially. A second run on the female fy_hof_skel.max (the template behind most corpus disagreements) is the natural follow-up, plus an animation-heavy variant aimed specifically at 0x014d.
Prompted by the Max IK Solvers clean-room spec (which covers Max's standard HI-Limb/Spline solvers — a different [CORE] system from Character Studio's biped IK, but the same geometry family as §10c's reverted 2-bone experiment), the dormant PMB_BIPED_IK solve was re-measured instead of re-theorized. Everything below is A/B data over the 3167 direct-ref biped files (per-file worst key delta via PMB_ANIM_DELTALOG, FK baseline vs solver), plus the ~/biped_anim_dataset2 b_ikb_* quarter-frame ground truth.
First, the engine-side fact that makes this worth solving at export time: NeL has no runtime limb IK. The engine's only hook is IAnimCtrl (nel/include/nel/3d/anim_ctrl.h, the per-bone post-animation callback the headers label "IK…"), and the only shipped implementation is CTargetAnimCtrl — a head look-at, used exactly once (ryzom/client/src/character_cl.cpp:736). Everything else is sampled keyframe tracks. So Character Studio's IK must be baked into the oversampled keys at export, exactly as the reference exporter did; there is no emit-a-constraint escape hatch, and the export-time solve is the only lever for this error class.
Dataset ground truth (E1): the old solve was better than its reputation. On b_ikb_static/b_ikb_out (the two blend-transition cases with real deviation), the §10c-era solve cuts the transition error 3–6× (L-thigh mean 0.049→0.008, max 0.097→0.022 rad) and leaves the at-key samples untouched (both at the 0.001 FK float-noise floor) — the "corrupts at-key exactness" framing in §10c does not reproduce on the datasets.
Corpus A/B (E2): but globally the old both-limbs solve is net-negative, split cleanly by limb. Mode PMB_BIPED_IK=1 (both limbs): 1152 improved / 1184 regressed, over-0.25-tol files 831→1180, mean worst 0.194→0.308. The losses are almost entirely the arm solve on upper-body gesture/emote clips (training_empathie 0.05→1.25, emote_squeamish, idle_malaucou, quartering_loop, first-person coup — ~1.2 rad ≈ 180° flips, a wrong-frame/degeneracy failure of the arm chain model); the wins are leg foot-plant cases (co_l2m_coup_fort 0.93→0.20, ab_idle_dague 0.75→0.14, emot_kneel 0.54→0.11). A real bug found on the way: the leg keytrack's IK fields (blend [12], world end-effector [18..20]) were read without the +2-per-extra-leg-link head shift that every other leg field applies (§10c) — 4-link mount/bird rigs fed the solve garbage (the nage_*_monture +1.14 regression exactly). Fixed.
E3 (PMB_BIPED_IK=2), the shippable-shaped variant: 3-link legs only + target-sanity guard. Arms and horse-link chains stay channel-FK; the solve skips when the stored world target and the FK ankle disagree by more than half the leg length (wrong-space/garbage input ⇒ FK). Corpus A/B: 1315 improved / 472 regressed (over-tol 831→669, only 4 files newly over tolerance, 166 newly under; median 0.121→0.099, mean 0.194→0.173); regression magnitudes are small (21 files regress >0.1 rad vs 240 improving >0.1). Dataset E1 re-verified identical under mode 2. Default behavior (env unset) proven byte-identical per-file over the full sweep.
The remaining structured class — the *_course run-cycle family (the bulk of the 472). Dozens of fy_hof_*_course variants share one identical signature (0.0965→0.2452 — same leg cycle under different weapon sets). Per-track probe on fy_hof_a_course vs its direct reference: E3 improves the right leg (calf 0.050→0.022, foot 0.029→0.011) while regressing the left (calf 0.049→0.245, thigh 0.044→0.070) — same solve, phase-shifted plant intervals, opposite outcomes. So neither approximation dominates per interval: the channel-squad FK and the 2-bone-toward-TCB-ankle path each win on different plant phases (the ankle's true in-between path — a roll about the active ikPivots pivot — is neither). The foot-rotation "regression" is derived, not primary (exported foot local = conj(calf world)·foot world; the foot's own world channel is untouched).
Where the next accuracy lives, in order: (1) the per-key ikPivots/ikPivotIndex/ikJoinedPivot data (§10d-bis manifests carry them; the storage offsets are known) — modeling the in-between ankle path as a roll about the active pivot instead of world-TCB of stored ankle positions would address exactly the course-family failure mode; (2) ikAnkleTension (stored per key, currently unused); (3) the [Max IK Solvers] §3.2 swivel/zero-map formulation as the reference for the knee-plane convention if a residual survives at solve level. Status: PMB_BIPED_IK=2 stays env-gated (off by default) pending the pivot-roll model; it is the measured best-known approximation for the IK-interval class, and mode 1 is kept only as the A/B artifact. SUPERSEDED 2026-07-08 (§10r): the pivot state was decoded from the leg keytrack record tail, the pivot-constrained model is implemented and corpus-proven, and it is the new default — modes 1/2 remain only as archived A/B artifacts.
The §10q item (1) happened: the leg keytrack record's tail turned out to carry the complete per-key foot-pivot state, the reference's in-between behavior was reconstructed from the direct references themselves (composing the sampled local tracks back into world chains — ikpath-class scratch tooling over --dump-samples + the parsed .anim), and every piece of the in-between rule was identified by exact numeric match rather than curve-fitting. The implementation replaces the E3 experiment as the DEFAULT evaluation for planted leg intervals; PMB_BIPED_IK=0 forces the old pure-FK evaluation (verified byte-identical to the previous default per-file corpus-wide at the time of the A/B; the later COM-turn defect fix below applies in ALL modes, so mode 0 now differs from the historical bytes exactly on the large-turn files), 1/2 keep the archived experiments.
Independent confirmation (vendor documentation + history, post-hoc): the shipped biped's IK is its own quaternion/TCB system (lineage: the PODA pseudoinverse locomotion work, SIGGRAPH 1985), controlled by exactly three per-key values — IK Blend (0..1), Body/Object space, and Join To Previous IK Key — with Planted = blend 1 + Object + Join-on, Sliding = blend 1 + Object + Join-off, Free = blend 0 + Body. The docs state verbatim that at blend 1 "a spline path is computed through the keys of the hand, and the hand moves along that spline; joint angles for the rest of the arm are computed to allow the hand to follow the spline" — which is precisely the W-spline + derived 2-bone solve implemented here — and that "the foot pivots at the PREVIOUS key's pivot point" under Join-to-Prev, which is why sessions key on the interval-START pivot index and why a pivot change needs two consecutive same-pivot keys (the pB handoff record). Intermediate blends are documented as a weighted mix of the FK arc and the IK spline path — naming the proper model for the §10d-bis transition-ramp class when it's next attacked. This maps the decoded fields: [12] = IK Blend, [11] = the space flag (2 = Object/world — the whole legacy corpus plants in world space; 0 = Body/COM space, which is why Body-space arm records store COM-relative end-effectors), [25] = Join-to-Prev (previously "plant-establishing flag", semantic now confirmed by the preset table).
The storage decode (leg record tail, offsets before the +2-per-extra-leg-link head shift like every field above index 2 — see §10c):
| Field | Meaning |
|---|---|
[98] (int) |
ikPivotIndex, 0-based (the manifests' 1-based ikPivotIndex minus 1). Pivot 2 = mid-heel, 5 = mid-ball (= the TOE-ATTACH point exactly — \|d_toe\| == \|pA−ank\| to 5 decimals, so §10c's "toe-attach stays world-planted" observation IS the pivot constraint); on ARM records it is 0 (the wrist itself — pLocal = 0). |
[101..103] |
pA — the active pivot's world position at this key's pose (Y-up stored, like every keytrack position). Arm-length-consistent with the ankle (\|pA−ank\| constant per pivot index, 0.115 heel / 0.178 ball on fy_hof) at every genuinely planted key. Zero on scripted keys (the Max 9 datasets — setKey never populates it). |
[105..107] |
pB — the PREVIOUS key's pivot re-expressed at this key's pose (== pA when the pivot didn't change). At a pivot handoff key (heel→ball roll), pB records where the OLD pivot ended up — verified: computed heel position at the roll's end key equals stored pB to 1e-5. NOT always a valid old-pivot record: when the old pivot stopped being tracked before the key, pB just equals pA (the L-side course case) — validity is testable by arm-length consistency. |
[11] (int) |
2 on artist-authored planted/sliding keys (the whole legacy corpus), 0 on free keys and on every scripted dataset key. Reads as the key's IK space/type marker (body vs object space — the corpus plants are object-space); not consumed by the model (the pivot data itself gates). |
[25] |
1 on plant-establishing / pivot-selection keys in the corpus (course heel strikes, the free key where the artist picked the next pivot); exact semantic unresolved, not consumed. |
Also probed on the way: the a_ik_ankle0/05/1 dataset triplet differs ONLY in the uninitialized-noise chunk 0x0709 — the generator's ankle-tension set never took, so ikAnkleTension's storage remains unlocated (and all three cases were always the same data, which is why they all sat at the ε-floor in §10d-bis).
The in-between rule (each clause pinned by exact reconstruction against direct references):
ankle(t) = W(t) − R_foot(t)·pLocal, with pLocal = conj(R_foot(s))·(pA(s) − ank(s)) at the session's first key. With the reference's own foot rotation, this reproduces the reference ankle to FLOAT NOISE at every in-between frame (fy_hof_a_course L, both plant intervals).W_m = footPos_ch(m) + R_ch(m)·pLocal), not read from pA — this makes the whole solve an exact no-op at keys by construction (and bit-stable: the eval skips when the target matches FK within 1e-7, verified exact-0 per leg at every stored key), and makes stale/garbage pA harmless beyond the arm vector itself.D (channel + feather) must be either ~zero (a genuine plant, the core case) or LARGE (a deliberate movement the reference interpolates: the course heel strike's 27 cm descent, fy_hof_emot_kneel's get-up); the ambiguous small band (0.5 cm < D < 5 cm — contact adjustments: zo_hof_marche's 1.8 cm stride release, fy_hom_strafe_gauche's 4.3 cm landing) evaluates near-FK in the reference and stays FK — any modeled target error there explodes through the knee near full extension (the law-of-cosines derivative blows up at d→L1+L2: a ~1 cm target error became a 0.19-0.24 rad calf miss on the straight-leg cases). pB-validated handoffs are exempt (course R's roll has D = 4.7 cm and is float-exact under the spline). This one data-driven window resolved every conflicting per-file preference the hold/skip heuristics tried to capture (marche/strafe want FK, kneel/engarde/course want coverage — all simultaneously at their best-known values under it).(phiT − phiNow)·sgn form with sgn from a cross-product test is numerically unstable exactly when the leg points straight at the target (phiNow → 0, cross → 0) — a noise-flipped sign rotated the hip the wrong way by 2·phiT (zo_hof_marche's straight-leg release frame, a 0.24 rad ankle miss). Replaced with the robust signed-angle form atan2((thX×u)·hz, thX·u) + phiT.R(t) = AxisAngle(Z, yawAngle(t))·yawFrame(t). The foot follows the key-interpolated body turn and none of the live COM motion. PMB_BIPED_IK_ROT: yaw (default) | full | run | off for A/B.pA == ank, and the strike-class carries ~7 cm palm pivots whose space varies per key with the [11] Body/Object flag), but the wrist-pin variant measured net-negative on the gesture corpus (training_empathie 0.113→0.158: the reference's held wrists are not position-splined either) — legs only; the generic limb machinery is kept behind PMB_BIPED_IK_ARMS=1 for a future arm-pin investigation. The coup_fort strike class (hands pinned on the weapon, worst 0.66) is the largest remaining single class.[4..7] and the SIGNED TURN ANGLE at [1] (s_k == AxisAngle(Y-up, −angle_k) exactly on pure turns), and interpolating the quat alone is wrong for large per-segment turns — the squad's shape diverges from the reference's own scalar-angle TCB on a 162° segment (fy_hof_demitour_go's exported COM was 0.728 off mid-turn under the quat squad, dragging both feet with it; the stored angle also wraps at ±180°, fy_hom_emote_pompous). Fixed by an angle+residual decomposition s(t) = AxisAngle(Y, −angleCh(t))·residCh(t) (angle unwrapped, TCB scalar), GATED to near-pure-turn tracks with a pathological step (max |Δangle| > 2 rad AND max residual < 0.6 rad): on non-pure turns (the death anims pitching while spinning — fy_hof_*_mort) the independent angle/residual interpolation composes wrongly, so ordinary tracks keep the quat squad. demitour_go 0.728 → 0.148 FK / 0.114 modeled; pompous 0.676 → 0.159; a1md_mort 0.165 → 0.122.The remaining open items. (a) The true in-plant foot rotation is solver-derived, not interpolated: the reference's mid-plant foot rotation passes OUTSIDE the geodesic of its own keys (measured progress 1.85–2.25 on coup_fort's near-identical keys — real excursions, not any time-warp of the key interpolation), lags on the course ball plant (progress 0.16 at u=0.5), and no tested frame/channel (stored-composed, world, key-yaw, run-local, COM-frozen, slerp/cosine, min-rot arm tracking, toe-world-hold, ankle-joint squad off the solved calf) explains all cases — Character Studio evidently solves it from the leg/body state (the loose-ankle response; ikAnkleTension = 0 corpus-wide is consistent). This bounds the model's floor at ~0.01–0.1 on the worst in-plant frames. (b) The D-window's FK fallback classes (release/landing contact adjustments) and the scripted body-space blend ramps (§10d-bis) remain approximations. (c) The arm-pin class (above). A dedicated Max-side probe (dense-GT planted intervals with a moving body under varied ankle tension, plus hand-pin cases) is the designed next step for (a) and (c).
Corpus A/B (per-file worst key delta over the 3167 direct-ref biped files, baseline = the pre-§10r default): 1521 improved / 123 regressed / 1523 same; files over the 0.25 informational tolerance 831 → 594 (only 1 newly over); median worst-delta 0.1208 → 0.0686, mean 0.1945 → 0.1500; regressions > 0.02 down to 25 (largest: fy_hom_demitour_dr +0.147 — the COM-interaction class where our COM still deviates slightly and the absolute-frame foot anchoring converts what used to be relative-error cancellation into absolute error). Direct tiers stay clean: biped 7/7 and non-biped 10/10 byte-identical, T3 structural failures 0. The b_ikb dataset GT is unchanged (no valid pivot data in scripted keys → FK, byte-identical dumps). Six full-corpus sweep iterations were needed to converge the rule set (live-yaw, hold, tail, and handoff-skip variants all measured and rejected at scale — per-file probes systematically lied about corpus-wide effect). Method notes: (1) probe measurements must never run against a stale binary — one mid-session git stash && ninja && stash pop "clean build" quietly relinked the tool from HEAD and produced an hour of phantom conclusions (rotation modes "identical", nonexistent regression classes) until the sweep-vs-probe numbers refused to reconcile; every rule above was re-verified against a genuine build afterwards. (2) The per-file worst-delta CSV (PMB_ANIM_DELTALOG) + abcmp.py-style A/B is the only trustworthy arbiter for heuristic changes in this domain.
Ranked by expected corpus impact. Standing workflow for ALL of these: (1) regenerate the FK baseline CSV first (PMB_BIPED_IK=0 PMB_ANIM_DELTALOG=... anim_corpus.py --t3 -j12 — the session scratchpad CSVs do not persist), (2) develop against per-file probes (cmp_tracks-style worst-per-track vs ~/pipeline_export/common/characters/anim_export), (3) accept/reject ONLY on a full-corpus A/B — per-file probes repeatedly inverted at scale during §10r.
(1) Arm-pin class — the largest single remaining error mass (strike/coup files, hands-on-weapon; fy_hof_co_l2m_coup_fort L UpperArm 0.66, whole a2m/l2m coup families sit 0.4–0.9). Two prerequisites, both probe-able without Max:
[11] flag selects Body vs Object space per key, and the record stores end-effectors at [14..16] (body) AND [18..20] (world) — for arms the observed scale of [18..20] varied per file (empathie tiny vs coup chest-height), so first establish which slot + which frame a Body-space arm key actually populates. Test purely from corpus data: for each candidate frame F (world, COM-translated, COM-rotated, pelvis), check |pA − ank| arm-consistency AND pLocal = conj(R_hand_F)·(pA − ank) CONSTANCY across the session's keys. The frame that makes pLocal constant is the storage frame. fy_hof_co_l2m_coup_fort's arm session k4..k9 (pivot idx 5, arm 0.0752) is the ready-made test case.PMB_BIPED_IK_ARMS=1 — already implemented for the wrist-pin case) with the decoded space, elbow = Z-hinge like the knee, no rotation override (hand rotation is parent-composed). Gate exactly like legs (D-window). Expect the wrist-pin gesture case to stay net-negative (measured: empathie 0.113→0.158) — the win should come from the palm-pivot strike class only, so consider gating arms to sessions with |pLocal| > ~2 cm.(2) In-plant foot ROTATION — needs a Max-side probe; all interpolation candidates are exhausted. Extend gen_biped_fields_probe.ms (Max 9, pipeline_max_corpus_test) with a dedicated IK-rotation dataset: one leg planted (blend 1, object space, fixed pivot) while the COM (a) translates forward, (b) yaws, (c) pitches/lunges, (d) combinations, each authored twice with ikAnkleTension 0 and 1 — quarter-frame biped.getTransform GT via the existing manifest format. Hypotheses to test against the GT, in order: (i) foot orientation = minimal-rotation transport of the previous frame's orientation subject to the pivot constraint (a no-twist/parallel-transport solve — would explain "outside the geodesic"); (ii) foot = world-held but ankle-joint-limited (clamped cone around the calf; the loose-ankle reading of the docs); (iii) ankleTension interpolates between (i)-like loose and channel-FK stiff. Also re-attack the ikAnkleTension storage hunt with UI-authored files if scripted setKey-era properties keep not taking (the a_ik_ankle0/05/1 triplet came out byte-identical outside noise chunks — the scripted set silently failed; a hand-authored pair with tension 0 vs 1 diff'd with --diff-rig pins the field in one shot).
(3) The demitour_dr / COM-interaction class (+0.147, plus a tail of small regressions: train_perception_end, tr_hom marche_coup, magie_cur_init ~+0.06). Two independent leads:
maxStep > 2 rad AND maxResid < 0.6) with the actual rule — the current gate is corpus-arbitrated, not decoded. The angle+residual decomposition may well be correct at ALL step sizes with the right residual frame (e.g. residual interpolated in the turn-following frame instead of composed independently); the mort-class failure that forced the gate is likely a residual-frame artifact.PMB_BIPED_IK=0, cmp the COM rotquat track alone; if it's >0.02 on the regressed set, fixing the turn rule (above) removes the class; if the COM is clean, the regression is genuinely the model's absolute anchoring and the yaw-channel should be re-anchored to the REFERENCE-implied COM... which we don't have at export time — then it's a floor, document and close.(4) Blend-transition ramps (the §10d-bis b_ikb class, ~3–5 cm) — now has a documented model: intermediate IK Blend = weighted mix of the FK arc and the IK spline path. Implementation sketch: for a 0→1 interval, evaluate both the channel-FK pose and the pivot-model pose (needs a pivot session extended INTO the transition interval — use the planted key's pivot), then blend the ankle targets with w(t) = the TCB-interpolated blend channel value, and solve. Validate against the recorded quarter-frame GT in ~/biped_anim_dataset/dataset2 manifests (b_ikb_static, b_ikb_out, a_ik_blendtrans) BEFORE any corpus sweep — the corpus's artist keys are 0/1 so corpus effect comes only via transition intervals adjacent to plants.
[11] (the datasets are Body-space scripted keys; the corpus is Object-space artist keys — the blend rule may differ by space).(5) Cheap hygiene items. (a) fy_hom_swim_* and a few files regressed ~0.06 purely from the turn-fix gate boundary — re-check them after (3). (b) The IKDBG/IKDBG2 debug prints and the PMB_BIPED_IK 1/2 archived experiment paths can be deleted once (1)–(3) land (keep =0 as the permanent A/B baseline). (c) biped_coverage.py's IDENTIFIED tier should gain the now-decoded pivot fields so the coverage number reflects §10r. (d) If a future rule needs per-TRACK regression attribution at corpus scale, extend PMB_ANIM_DELTALOG to emit file<TAB>track<TAB>delta — the per-file worst hid the strafe-class ankle regressions behind arm-class noise for two sweeps. (e) Locate where Max persists the time-slider range RESOLVED same day via a user-supplied single-variable probe — Config stream 0x20b0/0x0060 (see §2c); the generator sets it. (The "corpus sits at the default" read was wrong — an adjacent-int-pair scan can't see values split across sub-chunks; bye really carries 12000.)
(6)–(8) from the PODA lineage — see poda_1985_notes for the digest of Girard & Maciejewski's SIGGRAPH '85 paper (the published design ancestor of this biped system) and three further experiment leads extracted from it: null-space center-angle redundancy resolution as the candidate solve for 4-link legs and the hip-swivel choice; the per-leg constant-force ballistic superposition as the re-opened lead on the keyless-vertical/mort_idle constant; and a piecewise world-hold-with-key-snaps candidate for the in-plant foot rotation (a rule NOT in §10r's rejected list — the paper holds the whole foot frame by per-frame world re-expression, which would naturally produce the observed outside-the-geodesic reference behavior).
Reference points for whoever picks this up: the model lives in biped_anim.cpp buildPivotSessions()/applyPivotIk() (+ the COM turn decomposition in buildChannels()/evalFkAt()); every rule has its evidence file named in the comment at its implementation site; probe tooling patterns (ikpath reconstruction, biptab record dumps, gtcmp manifest checks, abcmp sweeps) are described above in this section and are ~100-line scripts to recreate; the b_ikb/a_ik datasets with quarter-frame GT live in ~/biped_anim_dataset* with manifests.
Beyond the anim tool: for the full gap analysis of pipeline_max against everything build_gamedata exports through 3ds Max — the four missing small formats (.cmb/.pacs_prim/.veget/.clod), the skinned/MRM shape depth, and sized session plans — see pipeline_max_coverage. For the catalog of corpus files with known defects, era-mismatches, unresolvable XRefs, live-Max quirks, and other per-file anomalies — organized by defect class so a future session can look up "why does this file behave this way" without re-deriving it from this document's session narrative — see defective_max_files.
The Biped (0x9155) system object is now a TYPED scene class: pipeline_max/biped/biped_system.{h,cpp} (CBipedSystem, superclass 0x60 Object) with pipeline_max/biped/biped_anim_track.{h,cpp} (CBipedAnimTrack) as the keytrack codec. This is the library-level "edit animations programmatically" surface the parse&modify&save path (§10j) was building toward.
Mechanism. CBipedSystem::parse runs the CObject chain (references etc.) then drains the remaining chunk list — in storage order — into an ordered token vector via peekChunk/getChunk (front-pop only, so no out-of-order warnings). The 13 keytrack chunk pairs (§10c table: 0x012c/d horizontal … 0x0149/a pony2) are decoded into CBipedAnimTrack objects; every other chunk stays a raw pass-through token. build re-emits the token list in original order, re-encoding lifted tracks from the typed containers — so an edit through trackForEdit() reaches the written file, and an untouched file rebuilds byte-identically. Any pair that fails the size-consistency decode is left raw (byte-identity always wins over typing).
Codec fidelity. CBipedAnimTrack stores header extras, per-key time records and per-key data records as raw uint32 rows (float views on top) — bit-exact by construction, with record sizes INFERRED from chunk sizes, which covers the 4-link legShift records and the 26-dword tail time records without special cases. The vertical track's two banks are handled natively (bank sizes solved with a prefer-13-dwords rule). resetKeys() primes freshly authored keys with the corpus-default time-record slots (index-as-float at [1], TCB 25/25/25 at [7..9], ease 0 at [5..6]).
Read paths unaffected. findChunkAnywhere also scans the pre-clean chunks() container (which keeps holding every original chunk after parse), so biped_rig.cpp/biped_anim.cpp keep reading raw bytes; --dump-rig/--diff-rig were switched to chunks() so their dumps stay complete. Consequence for editors: after editing a typed track, the same in-memory scene's raw reads still see the ORIGINAL bytes — write the file and reload it for verification (the author tool does exactly that).
Gate: full anim corpus after the class landed — T1/T2 4452/4452 biped + 71/71 non-biped byte-identical (the typed lift/re-encode is exercised on every biped file), T3 unchanged (7/7 direct byte-identical, 0 structural fails, worst-delta median 0.0689). CBipedAnimEval also gained an override-keys constructor (evaluate a caller-built SBipAnimKeys instead of parsing the scene) — the authoring passes iterate candidate key sets against the exact exporter math without touching the scene.
pipeline_max_export_anim --author-jump <skel.max> <idle_source.max> <out.max> (biped_author.cpp) synthesizes a complete, freshly authored asymmetric jump emote onto the fy_hom common skeleton — the first WRITE consumer of the §10c decode (every conversion applied in reverse) and of the §10t typed keytrack surface. Delivered as ~/fy_hom_emot_jump.max for Max 9 validation.
Pipeline. (1) The common idle pose is lifted from an existing animation's FIRST-KEY records (bit-exact through the typed tracks; the unidentified cache slots — [39..45], [50+10k..53+10k], per-finger [56+10k..59+10k] constants — are carried verbatim, corpus-proven pose-inert: files at the same pose ship wildly different cache values). (2) The skeleton is walked (PMAX_RIG) and the idle templates evaluated on it (the new override-keys CBipedAnimEval ctor) to calibrate COM-local axes, limb attach offsets, per-limb twist residuals, and the foot pivot arms. (3) The choreography is authored as piecewise-linear channel tables in WORLD/COM-relative terms (COM ballistic keys, pelvis/spine/head/pony deltas, wrist/ankle targets, foot pitch, toe flex, finger curls, clavicle deltas, elbow/knee swivels); a pass-A eval provides the time-varying hip/shoulder attach frames; a 2-bone solve with the EXACT figure-local offsets (hinge angle bisected from |o1 + Rz(a−π)·o2| = d, align + spin-to-plane + calibrated twist) produces the limb poses; every stored field is then written through the inverted §10c conversions. (4) Pass-B verification: end-effector-at-target (exact), plant-pivot invariance, quarter-frame pop sweep, boundary-equals-idle; the file is written through a freshly loaded scene (all other streams verbatim) and re-verified after reload (exact).
Findings new to this session:
pLocal_L = (x, y, −z) of pLocal_R under the qMirrorLR convention; the world-plane mirror is WRONG (the idle stance splays the feet). The constructed L ball pivot reproduces victory's stored legL piv-5 pA exactly.Choreography (30 fps, frames 0..56, single-line summary): idle → anticipation crouch (weight onto the right leg, arms sweep back — left further, spine flex, head nod) → drive with heel roll over the ball pivots → staggered toe-off (R f17, L f18) → ballistic flight (exact parabola keys every 2 frames; asymmetric tuck: right knee high, left trailing; left-arm overhead punch with open hand, right arm abducted; ~10° left body twist; ponytail trailing; toes pointed) → staggered landing (R f32, L f34, ball-first) → absorb crouch → recover with overshoot → exact idle key at f56. Both feet plant at their idle pivots (the emote returns in place).
Gates: authored file T1/T2 byte-identical; ordinary .anim export runs clean on it; full anim corpus re-swept green after the authoring code landed (T1/T2 4452 + 71, T3 unchanged).
Max 9 validation round (same day). The file loads and plays in 3ds Max 9 SP2 with the intended poses. Two observations resolved: (1) the biped opened in FIGURE MODE — chunk 0x0109 is the figure-mode flag (§10p corrected; pinned by the b00_baseline isolation diff), and the generator now clears it, so the output opens straight in Animation Mode; the commit's cache side-effect chunks (0x01f9 length table, 0x0258/0x0259/0x025d/0x025e shadow bank) are deliberately left stale — they only drive unkeyed channels (§10n/§10o) and every channel is keyed. (2) The time slider stays at the 0..100 default — as it does in the REFERENCE anim files (fy_hom_emot_bye/dance carry byte-identical scene-header slots to the skeleton; the artists never adjusted sliders), so this matches corpus convention and does not affect export (ranges come from key spans, §10c); where Max actually persists a non-default slider range remains unlocated (candidate §10s micro-probe: change the range, save, diff).
Two new tools, both driven by drafts/pipeline_max_coverage.md's Session-1 punch list. Both link the real NLPACS types directly (NeL::pacs — confirmed fine to link, it's in-engine, not a heavy external dependency) rather than hand-rolling duplicate structs/enums, and pipeline_max_export_cmb additionally links NeL::3d for the real NL3D::CQuadGrid weld grid. Corpus scope for both was read out of build_gamedata/the workspace, not guessed (see below).
pipeline_max_export_pacs_prim (nel/tools/3d/pipeline_max_export_pacs_prim/main.cpp) replicates processes/pacs_prim's NelExportPACSPrimitives path: selects geometry-category nodes whose object class is the scripted "PACS Box"/"PACS Cyl" plugin (nel_pacs_box/nel_pacs_cylinder.ms, ClassId {0x7f374277,0x5d3971df}/{0x62a56810,0x4b3d601c}, "extends" Box/Cylinder). These are "extends" scripted plugins: reference 0 is the delegate real Box/Cylinder GeomObject (its own old-style ParamBlock, superclass 0x8, holds the dimensions), reference 1 is a CParamBlock2 with 12 custom params (Reaction, Enter/Exit/OverlapTrigger, CollisionMask, OcclusionMask, Obstacle, Absorbtion, UserData0-3) in fixed MAXScript declaration order (no explicit id:, so index == declared order). Position/Orientation come from the node's world transform (getNodeTM); orientation is CExportNel::getZRot of the world I-axis for boxes, always 0 for cylinders. Output is the real NLPACS::CPrimitiveBlock::serial over a NLMISC::COXml stream — exact by construction, no hand-written XML. One quirk reproduced verbatim rather than "fixed": Reaction = (reaction-1)<<4 (MaxScript radiobuttons are 1-based) does not land on UMovePrimitive::TReaction's own named "Stop" value (0x30 vs the enum's 0x40) — that's the reference exporter's own cast, matched bit-for-bit.
Corpus: PacsPrimSourceDirectories in each ecosystem's directories.py (DatabaseRootPath + "/decors/vegetations", i.e. stuff/<race>/decors/vegetations for desert/jungle/lacustre/primes_racines) — 509 .max files, 493 with at least one PACS primitive (a file with zero exports no file at all, matching the maxscript's own "tag anyway" behavior). All 493 are byte-identical against ~/pipeline_export/ecosystems/<eco>/pacs_prim on the first full sweep. pacs_prim_corpus.py drives T1/T2/T3, wired as pipeline_max_pacs_prim_corpus.
pipeline_max_export_cmb (nel/tools/3d/pipeline_max_export_cmb/main.cpp) replicates CExportNel::createCollisionMeshBuild/createCollisionMeshBuildList (nel_mesh_lib/export_collision.cpp): selects NEL3D_APPDATA_COLLISION/_EXTERIOR-flagged nodes (direct mode: geometry-category only; --ligo mode: also XRefObject nodes, not yet resolved — see gaps below), groups by the raw (not lowercased) NEL3D_APPDATA_IGNAME (default "unknown_ig"), and per group: extracts each node's evaluated mesh to world space (object TM = offset·nodeTM, same composition as every other tri-mesh reader in this project), assigns Surface=-1 for exterior-flagged nodes else a running totalSurfaces+matId offset that increments by (this node's own max material id)+1 after every processed node (exterior included, contributing +1 even though its faces read -1 — totalSurfaces resets per ig-name group), welds cross-node duplicate vertices with a real NL3D::CQuadGrid<uint32> (grid size 64, 1m cells, 5mm threshold — never welding within the same node), drops degenerate faces, and validates with the real CCollisionMeshBuild::link(false/true,...) (a group with link errors is dropped, matching the reference). Face Visibility[0..2] is a reference-side index remap of Max's own Face::flags edge-visibility bits (Visibility[0]←EDGE_B, [1]←EDGE_C, [2]←EDGE_A), reproduced as-is. Output is the real NLPACS::CCollisionMeshBuild::serial (header-only, plain binary).
Corpus scope, traced (not guessed) through the workspace: only continents/indoors/directories.py's RBankCmbSourceDirectories (== ShapeSourceDirectories, 16 construction .max files) is a genuine direct Max→.cmb export target; every other rbank_cmb_export reference directory is a downstream copy produced by a separate non-Max C++ tool (land_exporter), out of scope for this tool-porting session (land_exporter is already headless, nothing to port) — not out of scope for the pipeline: it's the step that turns brick-level .cmb (both this direct tier and the ligo tier below) into the placed-instance .cmbs that rbank's "Build rbank indoor" step actually consumes to build the shipped .rbank/.gr/.lr. The ligo brick corpus (zonematerial-*/zonespecial-*/zonetransition-*, the same 1201-file set zone_corpus.py/ligo_ig_corpus.py already drive) additionally produces .cmb per distinct ig name via exportCollisionsFromZone — a genuine Max export, called from the same nel_ligo_export.ms entry point as the ligo .ig export — output to ecosystems/<eco>/ligo_es/cmb/.
Edit Mesh modifier support (needed after the first sweep found 6/16 direct files with an OSM-Derived+"Edit Mesh" wrapper on their collision node): moves/vertex-deletes/face-deletes are decoded and applied (chunk 0x2500→0x2512→0x4000: 0x0140 moved verts, 0x0170/0x0270 deleted-vert/face bitfields — ported from pipeline_max_export_ig's already-validated decode, extended here to carry per-face material id + edge-visibility through the edit, which ig's containment-test-only copy doesn't need). Mirror modifier support (mods.dlm) is ported too, for completeness, though not exercised by anything in this corpus. Deliberately NOT implemented: created-vertex/created-face topology (0x0210 verts have no matching "created faces" record — the 0x0410-family is undecoded, same documented gap ig's copy already carries "sufficient for the clusterize containment test"). A node with genuine Edit-Mesh-added topology exports with those added faces missing; the tool warns explicitly ("has Edit Mesh created geometry... those faces are missing") rather than emit anything silently wrong — the raw 0x0210 payload's tail decoded as non-finite garbage on the one file that hit this before the fix (created vertices unreferenced by any face are now dropped rather than appended).
Landing state, direct tier (16 files): T1/T2 16/16 clean. T3: 10/16 byte-identical, 3/16 within the established float-precision tolerance (max(1e-5, 3e-7·|v|), same tier as ig/zone/shape — SSE-vs-x87/operation-order noise, not a topology difference), 2/16 with the documented Edit-Mesh-created-geometry gap above, and 1/16 unresolved: Zo_bt_Hall_Conseil has a single plain-EditableMesh collision node (no modifier stack at all, parented directly to the scene root, TCB-rotation local controller with zero keys) whose whole mesh is offset from the reference by up to ~4.4 units at world coordinates ~20000 units from the origin, with Y exactly preserved (X/Z diverge — consistent with an undiscovered rotation-about-Y discrepancy) — investigated at length this session (ruled out: modifiers, object-offset, parent chain, the file's stored current-time, the rotation controller's own default-value chunk resolving to a real non-identity value) without finding the root cause; left open rather than guessed at. cmb_corpus.py's DIRECT_DIFF_BUDGET (3) tracks these three known-open files; regressions beyond that fail the gate.
Ligo tier (measured, not gated in the corpus test — a real Max export step, not a no-op; see the cmb coverage-table row for how it feeds land_exporter/rbank): 1201 brick files swept, 48 distinct exported igs found reference matches for: 11 byte-identical, 15 within tolerance, 22 differ (unexplored in detail this session — plausibly the same Edit-Mesh-created-geometry gap plus the village_nb_0x/selection-order quirk ligo_ig_corpus.py already documents for ig on the same files). 6 of the 1201 files export nothing at all (exit 1, zero .cmb written) — every candidate collision node in each of these files is an XRefObject that the tool warns about and skips outright (checked directly: zonematerial-bassin-village_nb_01.max warns on >140 XRef nodes and its one real group then fails with "no GeomBuffers chunk" on the non-XRef leftover, producing no output whatsoever). This is a genuine gap, not a diff-tier nuance — XRef resolution is not implemented for cmb (the tool warns and skips; ig's existing resolveXRefObject is the template for adding it, not yet ported). By contrast, pipeline_max_export_ig's own direct and ligo corpus runs were re-checked this session and currently show 0 export failures on either tier — its "GREEN — 53/54 field-exact" coverage-table label is about per-ig field agreement among successfully-exported igs, not a status that's quietly absorbing whole-file failures.
Both tools' corpus drivers are pacs_prim_corpus.py/cmb_corpus.py (pipeline_max_corpus_test/), wired into ctest as pipeline_max_pacs_prim_corpus/pipeline_max_cmb_corpus (self-skipping, same convention as every other corpus test here).
VS2008/x87 precision pass (§2b's instrument, run against both new tools same-day): pacs_prim stays 493/493 byte-identical under the exact Max-2010-matching x87 codegen (no change — the format has no float precision sensitivity to begin with, it's just node-transform floats already computed the same way other exporters do). cmb's direct tier reproduces the x64/SSE build's outcome exactly — same 10 byte-identical + 3 within-tolerance + 3 open diffs, and the unresolved Zo_bt_Hall_Conseil offset measured bit-for-bit identical (4.36947180952392 vs 4.369471809417348) between the two builds. This rules out SSE-vs-x87 codegen as the cause of that offset — whatever is wrong there is a logic/decode gap, not float noise, confirming the earlier triage.
Open follow-ups for a future session: XRef collision-node resolution (cmb, --ligo mode); created-face topology decode (Edit Mesh 0x0410-family); root-cause the one unresolved direct-tier vertex offset (now confirmed non-precision-related); triage the 22 ligo-tier diffs individually.
Follow-up on §10g/§10g-bis's two open classes (shared cluster-containment defect + ligo selection-order divergence). Standalone ig: field-exact under whitelist retired from 53/54 to 54/54, budget deleted. Ligo ig: 89 → 28 diffs (68% reduction), same DIFF_BUDGET=89 still passes with 68 units of headroom. Both open classes were the same underlying defect, plus a maxscript-ordering subtlety.
Edit Mesh created-vertices are chunk 0x0130, not 0x0210. §10g/§10g-bis's decode read 0x0210 as uint32 count + Point3[count] (12-byte stride) with a comment "created vertices, faces not decoded". This turned out to be backwards for both fields: the real Max SDK MeshDelta layout is:
0x0130 = created vertices (uint32 count + (uint32 srcTag, Point3 pos)[count], 16-byte stride, srcTag is Max's cloned-from-vert history index, ignored on evaluation)0x0208 = created faces variant A (uint32 count + (uint32 srcTag, uint32 v[3], uint32 smGrp, uint32 flagsMatId)[count], 24-byte stride)0x0210 = created faces variant B (same shape without the srcTag, 20-byte stride — the pre-existing text ID and stride referred to this chunk)Consequence of the old decode: any node whose base object was fully deleted and rebuilt in Edit Mesh evaluated to an empty world mesh. On the standalone side that was TR_hall_reu_vitrine_decors's cluster links (base is EditableMesh + Edit Mesh + Mirror + Edit Mesh + ...; the _decors node in tr_hall_vitrine_reunion.max deletes all 8 verts of its editable base and rebuilds 12 through 0x0130 — the previously-shipped "two stories down" mirror-plane hypothesis (§10g "Primitive dataset round" note) was wrong, ~/decors_probe/manifest.txt — a gen_decors_probe.ms Max 9 dump the user ran this session — confirms axis=Z, offset=−24.9164, gizmo tz=+1.78046 → world Z ∈ [−31.27, 11.93] which our current pipeline reproduces exactly; the actual missing verts were the created-verts of the earlier Edit Mesh, not any Mirror plane). On the ligo side, the same defect explained the "fy_mairie's _puit_carre2/3 reference says 1 cluster, ours says 0" class in §10g-bis: _puit_carre3's Edit Mesh deletes all 8 base verts + 0x0130-creates 12 new ones. Fix: read 0x0130 in readEditMeshModApp (16-byte stride, skip the srcTag), retire the 0x0210 reader (any file that only carried 0x0210 didn't test-through — no corpus regression from removing it). 0x0208/0x0210 faces still not applied (clusterize link test is over vertices).
Ligo maxscript ordering: XRef-first pass PLUS tree-ordered per-category walk. §10g-bis's "selection-order divergence" class (desert nb01..nb05, jungle foret-18..21_village_a/b/c/d, some ilot_butte) came from two independent issues:
$geometry/$lights/$helpers in MaxScript iterate a depth-first scene-tree walk from the root, filtered by resolved (base-object) superclass — NOT the file's storage container order. On most corpus files these coincide (single flat tree, storage order === visit order), but not on files where children of two sibling parents interleave in file order. Implemented as buildTreeOrder(ssc, tmCache.SceneRoot, orderMap) — build parent → children list in storage order, then DFS pre-order from ssc->scene()->rootNode(). The per-category walk sorts matched nodes by this tree order per pass. Alone this closed desert-nb01 and the standalone TR_Hall_vitrine_reunion field-exactness immediately, but on the fy_module_village_nb_0x cluster of files it interacted with the second issue.exportInstanceGroupFromZone in processes/ligo/maxscript/nel_ligo_export.ms (unlike processes/ig/maxscript/ig_export.ms) has an extra for node in objects where classOf node == XRefObject do ... selectmore node loop BEFORE the three geometry/lights/helpers passes. Whether this changes ordering depends on whether MaxScript's $selection as array returns in selectmore insertion order or in Max scene order — Max SDK documentation is silent. Both hypotheses were measured against the corpus:
processes/ig path doesn't have this pass and its caller passes includeXRefFirst=false — its 54/54 result is independent evidence that the geometry/lights/helpers passes alone are the right shape for that path.PS-instance clusterize AABBox: reference DOES re-box in world space. §10g's decode says "8 world-transformed corners of the .ps shape's getAABBox". The literal reading (transform each corner in-place) was tried this session (matches the reference's own pss->getAABBox + node transform) and REGRESSES corpus (Matis hall_conseil / hall_vitrine_hall_reunion lose expected cluster links, direct diff 2 vs the prior 0). Keep CAABBox::transformAABBox (which re-computes an axis-aligned world enclosing box then takes its 8 corners) — the reference plugin behavior confirmed by corpus. Small note in the code (psShapeBBoxVerts) so nobody re-derives this.
Corpus status after this session (measured, not projected):
ig_corpus.py --all --gate-t3, pipeline_max_ig_corpus): T1/T2 116/116, T3 direct-ref 0 byte-identical + 54 uninit-bytes-only + 0 differ = 54/54 field-exact under --mask-uninit; processed-ref 40 match, 0 differ. --max-direct-diff 1 budget in the driver is now unused; can be tightened to 0 in a follow-up (kept at 1 this landing to avoid a spurious green-to-green regression risk while other exporters land alongside).ligo_ig_corpus.py --all --gate-t3, pipeline_max_ligo_ig_corpus): T1/T2 1201/1201, T3 direct-ref 71 byte-identical + 438 uninit-bytes-only + 28 differ = 509/537 (94.8%) match vs the previous 83.4%. DIFF_BUDGET = 89 untouched (any future regression past that still fails; tightening is a follow-up).Remaining 28 ligo diffs classified (not root-caused this session — read this as the current punch list, not settled ground):
CLUSTERPRECISION = 5mm of a cluster plane on borderline meshes (fy_sheriff / fy_warschool / street / taverne — extra cluster links on BrazierB.ps/similar). The reference plugin is x86 x87, we're x64 SSE; per §2b these agreed on cmb but here the accumulation chain is different. Same POS_EPS family §10g whitelisted for instance positions, not yet extended to the containment test. Real content risk: LOW (extra links harden visibility culling, they don't remove content).buildTreeOrder degenerates to storage order and doesn't help. The xref_resolvability block of Kaetemi's Max 9 gen_ig_selorder_probe.ms run pinned the actual maxscript rule: GetSourceObject false succeeds for every XRef in the file (my earlier "unresolvable source" hypothesis was wrong), and the exclusion is on the resolved source's own class, in two independent cases the maxscript literally distinguishes:
if (classOf sourceObject == XRefObject) then FAIL — nested XRef (the source .max's target node is itself an XRef pointing to yet another file). In the probe file: the 5 "ascenseur" XRefs (FY_acc_ascenseur_nb01, _porte_nb01, Omni_ascenseur_nb01, _ambient_nb01, FY_ascenseur_Dummy__nb01) all point at R:\graphics\...\fy_cn_module.max whose corresponding nodes are themselves XRefs pointing to R:\...\fy_acc_ascenseur.max → maxscript skips them.else if (superclassOf sourceObject == GeometryClass/Helper/Light) then selectmore — anything else falls through unadded. In the probe file: fy_module_col_nb01's source resolves to a SplineShape/sClass=shape (0x40) → skipped.$geometry/$lights/$helpers passes and end up LATER in the final $selection. Fix: the XRef pre-pass now does a single-step source resolution (inline: resolveXRefObject() by itself recurses through baseObjectOfObj, so we peel back one step and stop at node->getReference(1), then unwrap OSM/WSM derived-object wrappers to see superclassOf sourceObject as the maxscript does), rejects nested-XRef sources, and gates on the {Geom, Helper, Light} superclass set. Corpus impact: ligo 28→8 diffs (23 village-bundle files closed at once). DIFF_BUDGET re-tightened 28 → 8.Instrumentation added: the exporter's --dump mode now emits tree=N parent=<name> per node so a follow-up session can eyeball the tree-walk order directly. PMB_DEBUG_MESH=<node> still dumps per-op verts/tris/objz for mesh evaluation triage (used this session on TR_hall_reu_vitrine_decors and fy_mairie_puit_carre3 to pin the 0x0130 diagnosis).
Follow-up on §10v's four open items — three closed, one downgraded. Session context: an authoritative Max SDK MeshDelta document (from Kaetemi) was folded into the wiki's max_geometry_formats Part L (modifier stream + local-data stream framing + full legacy format spec + corpus-validated modern-format chunk map), and both parts pinned the last chunks the cmb tool needed.
Shared code — pipeline_max_export_common/edit_mesh_mod.{h,cpp} and xref_resolve.{h,cpp}. The Edit-Mesh mod-app decode + apply and the XRef-object resolver were both duplicated verbatim between pipeline_max_export_ig and pipeline_max_export_cmb; consolidated into two new modules alongside the existing max_scene/db_path/old_param_block/appdata_util shared library. EDITMESH::SEdits exposes the modifier-app record as: Moves (0x0140) / CreatedVerts (0x0130) / CreatedFacesA (0x0208, base-topology) / CreatedFacesB (0x0210, map-1 texture-vertex faces) / FaceAttribs (0x0220, per-face matID + edge-vis rewrites) / DelVerts (0x0170) / DelFaces (0x0270). The templated applyEdits<Face, Factory>(e, verts, faces, factory, facesMode) covers both consumers: ig passes facesMode=0 (verts-only, byte-preserving for its cluster-containment link test), cmb passes facesMode=1 (append base-topo faces via a factory that unpacks matID/edge-vis into cmb's SRawFace). ig corpus re-verified byte-identical post-refactor (T1/T2 116/116, T3 54/54 direct-ref field-exact + 40 processed-ref match, pipeline_max_ligo_ig_corpus DIFF_BUDGET=8 still exit 0). XREFRESOLVE::configure(reg) + resolveXRefObject(obj, depth) + baseObjectOfObj share one cache; cmb wired to both.
MDELTA_CHUNK (0x4000) sub-tree fully decoded. The wiki's Part L had a first-pass table from §10w; this session filled in the remaining chunk IDs against corpus evidence and the auth-doc's legacy-format Rosetta:
0x0208 = created BASE-topology faces, 0x0210 = created MAP-1 TEXTURE-VERTEX faces (not "variant B" of the same thing — different vertex spaces). Modern equivalents of legacy TOPO_NFACES_CHUNK (0x2775) and TOPO_NTVFACES_CHUNK (0x2776) respectively. Pinned by fy_hall_reunion: reference .cmb has exactly input+kept − delFaces + 0x0208.count = 248 faces on the failing node, applying 0x0210 to the base mesh produces edge-inconsistent topology CCollisionMeshBuild::link rejects outright.0x0220 = per-face attribute changes (matID + edge-visibility + hidden updates to the input face set). 12-byte stride (uint32 faceIdx, uint32 applyMask, uint32 values). Values-word layout matches legacy TOPO_ATTRIBS_CHUNK (0x2830): bits 0..2 = edge-visibility mask, bit 3 = face-hidden, bits 5..20 = material ID (extract (values >> 5) & 0xFFFF). ApplyMask-word is the legacy chunk's own bits 28..31 split off into a parallel word: bits 0..2 = per-edge apply, bit 3 = apply hidden, bit 4 = apply matID. Pinned by fy_hall_reunion's 82 entries reproducing the reference .cmb's exact matID distribution {57: 121, 58: 10, 59: 48, 60: 69} — the raw base mesh has matID 0 everywhere; 0x0220 rewrites them.Cmb — face-attribs applied, --ligo XRef-node collision extraction, link errors non-fatal. Cmb now:
applyFaceAttribs(e, faces) before the shared applyEdits — matID and per-edge visibility bits (in the same Vis0/1/2 ← EDGE_B/C/A remap the base-mesh decode uses) update the input face set.--ligo mode via the shared XREFRESOLVE::resolveXRefObject (the exact code ig has been using; every ligo brick's collision nodes are XRefs into shared source .max files, so §10v's "6 of 1201 files produce zero output" gap closes automatically).CCollisionMeshBuild::link errors to a stderr warning + write anyway. §10v's "a group with link errors is dropped, matching the reference" turned out to be a partial reading — the reference .cmbs in the corpus include edge-inconsistent meshes the plugin's own link check would reject, so the plugin must have shipped them (either the check was configurable, or a bug elsewhere fell through, or the link check itself changed later); either way, matching the reference means writing anyway.Direct-tier result (cmb_corpus.py --all --gate-t3): T1/T2 16/16, T3 = 10 byte-identical + 3 float-eq + 3 differ (same coarse-bucket totals as §10v's landing state, DIRECT_DIFF_BUDGET=3 still saturated), but the differing files' underlying quality moved by an order of magnitude: FY_hall_reunion face-index match went 229/248 → 247/248 (99.6%); its material distribution matches the reference EXACTLY {60: 69, 59: 48, 58: 10, 57: 121} (was {0: 96, 57: 105, 58: 4, 59: 24} — matID 0 → 60 rewrites via 0x0220 land field-exact). The three residuals: (1) 16 created-vertex positions offset by ~10 units (systematic — suspected 0x2510 mod-context TM applies to created verts and we skip it, unproven), plus one face-index remap that must live in a chunk we haven't identified; (2) Zo_bt_hall_Reunion_vitrine similar shape at larger scale (221/235 face diffs, 25 vert diffs — more complex Edit Mesh stack); (3) Zo_bt_Hall_Conseil unchanged (0 face diffs, 80/80 vert offset — the §10v "unresolved rotation-about-Y" file, still open, precision confirmed non-causal).
Ligo-tier result (informational): 30 byte-identical + 83 float-eq + 41 differ + 16 export failures = 170 igs measured against reference, up from §10v's 11 + 15 + 22 + 6 = 54. XRef resolution + attrib rewrites nearly triples the corpus-measured match count; the 16 export failures (up from 6) are the corpus-observed link errors on files where the auth-doc-inferred face-attrib application still doesn't fully reproduce the reference plugin's own topology fudge — the specifics not triaged this session.
Open follow-ups.
FY_hall_reunion (16 verts, ~10 unit systematic Y offset). Candidate: 0x2510 mod-context TM applied to created-vert positions before the object→world transform. Needs a probe of that chunk's value on this specific mod-app.FY_hall_reunion (face 18: OUR v=(54,55,53) vs REF v=(76,81,74)) — REF face 18 references created verts, ours references originals. Likely a face-vertex-remap chunk (modern equivalent of legacy TOPO_FACEMAP_CHUNK 0x2780) I haven't identified in the corpus's MDELTA_CHUNK sub-trees yet.Zo_bt_hall_Reunion_vitrine (221 face diffs) — same class as above at larger scale; solving the face-remap chunk should close it.Zo_bt_Hall_Conseil (80/80 vert offset) — the §10v-original unresolved file. Still open on its own separate terms (Y preserved, X/Z diverge, rotation-about-Y suspected, precision ruled out).Coverage-frontier work off the §10i M1 handoff. The M1 baseline was 2141 exported shapes (704 float-eq + 989 lightmap-bucketed + 64 mapext + 383 differ + 1 no-ref, 1449 references not produced across skinned/multilod/water/remanence/mesh-eval/flare/interface). Four things landed this session, in order:
Shape's Edit Mesh mod-app decode migrated onto the shared pipeline_max_export_common/edit_mesh_mod library (§10x). The shape tool had inherited ig's pre-§10w hypothesis where 0x0210 was read as vertices at 12-byte stride (it's actually MAP-1 texture-vertex faces at 20-byte stride — the modern equivalent of legacy TOPO_NTVFACES_CHUNK 0x2776), so nodes whose Edit Mesh appended verts silently garbled and nodes whose matID was rewritten by 0x0220 shipped raw base matIDs; the M1 corpus never surfaced it because the plain-editable-mesh diff class doesn't exercise these chunks. Retargeted onto EDITMESH::readModApp + applyEdits<SEvalFace, factory>(facesMode=1) with a shape-side applyFaceAttribs that lands per-face matID + edge-vis rewrites in SEvalFace::Flags (matID at bits 16..31 via the existing MAX_FACE_MATID_SHIFT packing) before the shared apply, plus a parallel-map-channel handler shape carries beyond ig/cmb (channels keep their own vert lists; face indices reordered in parallel with base face-deletes, extended with placeholder (0,0,0) UVs for created faces — same reasoning as cmb, 0x0210's tex-vert V[3] references the map-1 space and applying it against the base causes the edge-inconsistent topology CCollisionMeshBuild::link rejects). Shape T3 numbers stayed identical (278 float-eq + 58 differ + 25 mapext + 2 lightmap on common/objects, no regression), but one previously-broken file with real 0x0130/0x0208 content (ge_mission_fortuna_wheel_base) now legitimately reflects created-vert+face content (178→222 verts vs ref 212 — reference-era class or per-shape SDK-call divergence, moved but not root-caused). Three consumers now share one decode path (ig, cmb, shape) so the two copies don't drift again the way they already did.
CMeshMultiLod path lands — the M2 §10i handoff's 418-file multilod skip class fully closes. Structure: LOD 0 = the parent node itself; LOD 1..count from the slave nodes named in NEL3D_APPDATA_LOD_NAME + i (case-insensitive lookup — same nodesByName map exportFile already builds for the LOD-slave gate that keeps slaves from producing their own .shape). Per slot: MeshGeom = CMeshGeom (plain) or CMeshMRMGeom (per-slot NEL3D_APPDATA_LOD_MRM flag), DistMax + BlendLength from per-slot appdata, Flags = BlendIn|BlendOut|CoarseMesh (per-slot). StaticLod from parent's LOD_DYNAMIC_MESH. Materials/transform/shadow flags live at the base level and come from the parent node (parent-authoritative — same discipline the reference plugin uses on shared multi-lod material lists; slave face matIDs get clamped through buildMeshInterface's existing % numMaterials, so a slave with fewer materials than parent is safe). Two refactors on the way: evalAndBuildMesh(node, cache, base, max, out, stats) — pure mesh-eval + CMeshBuild construction against a caller-supplied CMeshBaseBuild, reused per LOD slot and by the single-mesh path; buildMeshGeomFor(node, buildMesh, numMaxMaterial) — CMeshGeom/CMeshMRMGeom factory gated by the node's own LOD_MRM. optimizeMaterialUsage is only on CMesh (not CMeshBase/CMeshMultiLod), so multi-lod skips it and installs an identity remap for the animated-material loop. Corpus impact: exported 2141 → 2559 (+418 exactly matches the multilod skip class), float-eq 704 → 767 (+63 multilod cases within tolerance), lightmap-bucketed 989 → 1263 (+274 multilod files with LightMap materials), differ 383 → 462 (+79, new class incl. e.g. common/construction/gen_mo_charognemammal 'materials: 3 vs 4' where slaves contribute materials the parent's list alone doesn't have — likely reference-plugin material-merging across LODs, not yet root-caused), export failures 0; SKIPCLASS multilod gone.
--compare-lightmap-mask mode + corpus driver wired — 1263 lightmap files → 982 lightmap-verified + 281 lightmap-diff. The calc-lm phase (that the standalone lightmapper will replicate downstream, per §9 T3 caveats) modifies LightMap-shader materials (appends lightmap textures + calc-lm-tweaked ambient/specular/shininess) and the vertex buffer (adds a second UV set for lightmap coords, then re-dedups verts against the composite UV, changing vertex count and index content), so full byte-compare against a lightmapped reference cannot pass without simulating the whole thing. A concrete example, ma_acc_ascenseur.shape: our 4262 bytes vs ref 5807, materials 223 vs 312 bytes each (+89 bytes = ~1 texture + StageEnv config), VB format 0x7 vs 0xf (+ second UV set), VB size 32 vs 40 (+8 = the extra UV), 79 vs 98 verts (dedup boundary difference). The mask compares only what the lightmapper does NOT touch: transform (DefaultPos/Rot/Scale), material COUNT + per-material shader type + Blend/ZWrite/2Sided/AlphaTest + slot-0 texture presence, matrix-block/rdrpass counts, per-rdrpass material assignment + index COUNT (topology is preserved under lightmap-UV re-dedup even when content isn't). Under mask, size-differs-alone doesn't force verdict 2 (calc-lm output is legitimately larger). The corpus driver routes the lightmap bucket through this mode, splitting it into lightmap-verified vs lightmap-diff; --gate-t3 counts verified into --min-identical and diff into --max-diff. The 982 close a real coverage gap that had sat as a raw bucket (upgrade from "we exported it and stopped" to "we verified the base structure is what the ref would have started from before calc-lm ran"); the 281 lightmap-diff are files whose base structure diverges from the ref beyond just lightmap-added fields and are the next natural triage target.
Parametric primitive shape path — extracted buildParametricMesh to the shared library. The 15 Box + 18 Sphere + 13 Cylinder + Plane nodes that used to warn object class not implemented now produce real .shape files. pipeline_max_export_ig/main.cpp:961's primitive topology generator (ground-truth exact per ~/prim_mesh_dataset, §10g primitive dataset round: vertex order, face order, windings incl. multi-segment grids and the negative-height winding flip) moved to pipeline_max_export_common/parametric_mesh.{h,cpp} (PRIMMESH namespace, SPrimTri output type, well-known class-id constants exposed). Shape's mesh_eval gains extractParametricPrimitive(): SCLASS_PBLOCK ref-0 pblock → OLDPBLOCK::readOldParamBlock → PRIMMESH::buildParametricMesh → SEvalMesh with smGroup=1 (Max's default single smoothing group for primitives) + matID=0 + all-edges-visible + empty map channels. UV mapping-coord generation per primitive (Box → 6-face box, Cylinder → cylindrical, Sphere → spherical, Plane → planar) is deliberately NOT implemented this session — the reference builds them via Max's own SDK, requires per-primitive UV formulas and their "generate mapping coords" checkbox state; skipped means textured primitives will diff on UV, untextured/collision ones will match. Corpus: exported 2559 → 2596 (+37 parametric shapes), float-eq +4, differ +27 (no UVs), mapext-bucketed +6. Ig's own copy of buildParametricMesh still lives in main.cpp untouched (behavior-preserving retarget onto the shared library is a mechanical follow-up).
Corpus T3 state at end of session (shape_corpus.py --all -j 16): 2596 exported (was 2141; +455 = 418 multilod + 37 parametric): 0 byte-identical + 771 float-noise-eq (was 704, +67) + 489 differ (was 383, +106) + 72 mapext-bucketed + 982 lightmap-VERIFIED + 281 lightmap-diff (was 989 bucketed with no verification tier) + 1 no-ref; 994 reference shapes not produced (was 1449, −455 by the multilod+parametric closes) — remaining skip classes: skinned 654 (Physique/Skin modifier, the biggest single remaining), water 221, remanence 82, mesh-eval 73, flare 13, interface-mesh 9. Material anim (.anim) 642 byte-identical, 0 differ. T1/T2 corpus stays 8632/8632 (this session touched no library-side classes, only export logic).
Handoff — the open items after this session:
CMeshMRMSkinned (654 files, the biggest remaining not-produced class). Per-vertex bone weights in Physique's per-node mod-app (undecoded); bone name/order via buildSkeletonShape's nodeMap (share via pipeline_max_export_common). Reference: §10's IPhyRigidVertex + IPhyBlendedRigidVertex notes.CWaterShape::setShape(CPolygon2D) + setWaterPoolID + setEnvMap/setHeightMap/setColorMap from the water material's PB2. hasWaterMaterial(node) already identifies these; the buildWaterShape path is unimplemented.materials: 3 vs 4 class), some primitives producing coarser mesh than the reference expected, Unwrap UVW effects not applied (mod:02df2e3a, 25 uses).0x000f72b1, 76 uses) — the biggest still-unhandled modifier; standard-plugin planar/cylindrical/spherical/box UV projection.0x02df2e3a, 25 uses) — user-authored UV overlays.0x8ab36cc5, 20 uses) — Free-Form Deformer lattice.0x40c7005e, 110 uses) — our own plugin, vertex color paint modifier; would need lookup in plugin_max/nel_vertex_tree_paint.multi_mtl slave-material merging (common/construction/gen_mo_charognemammal class) — multi-lod slaves whose materials aren't all in the parent's list produce ref-larger material counts.Continued from §10y (the multilod/parametric/lightmap-mask round). Two more not-produced classes close, in coverage-first order:
EditablePoly extraction — closes 60 of the 73 remaining mesh-eval failures. <PolyObject base data> (max_geometry_formats Part C) sits in the SAME CGeomBuffers container the tri path already reads, under distinct ids: 0x0100 = poly vertices (CGeomPolyVertexInfo — position + per-vertex u32), 0x011a = poly faces (CGeomPolyFaceInfo — variable-size records with vertex-list, optional matID / smoothing group / triangulation cuts). New typed accessors polyVertices()/polyFaces() on CGeomBuffers alongside the existing triVertices()/triFaces(); mesh_eval gains extractEditablePoly() that reads them, drives CGeomObject::triangulatePolyFace (already public/static, pipeline_max_dump::exportObj uses it) and emits one SEvalFace per resulting triangle — matID from CGeomPolyFaceInfo::Material packed into flags at MAX_FACE_MATID_SHIFT, smoothing group from CGeomPolyFaceInfo::SmoothingGroups, all edges visible (poly triangulation subdivides the interior; Max's own convention is invisible interior edges, harmless either way — the export path reads the low 3 bits only for shading). Map channels NOT read this pass — EditablePoly's MNMesh stores per-poly map channels differently from the tri path's 0x2394/0x2396 pair (Part C notes the MNMesh sub-container is out of scope); textured EditablePoly nodes will therefore diff on UV until the poly map decode lands, same practical trade-off extractParametricPrimitive already accepts. Corpus impact: exported 2596 → 2609 (+13 EditablePoly nodes), mesh-eval skips 73 → 14 (the 14 residual are other object classes: obj:0000000a, obj:00001040, obj:00001065 — still to be identified next); differ 489 → 502 (+13 without UVs).
CWaterShape path — closes 220 of 221 water shape files (the second-biggest not-produced class after skinned; ~10% of the total shape corpus). Replicates CExportNel::buildWaterShape (plugin_max/nel_mesh_lib/export_mesh.cpp:1675) into a self-contained WATERBUILD module (pipeline_max_export_shape/water_build.{h,cpp}). Pipeline: gate on the existing hasWaterMaterial(node) (NeL material bWater=1); evaluate the node's mesh through MESHEVAL::evalNodeMesh (respects XForm + Edit Mesh modifiers, per §10y); transform verts into node-local space through objectTM · Inverse(nodeTM) (same construction buildMeshInterface uses); project onto Z=0 (fixed 4×4 projection matrix from the reference) and take the convex hull → CPolygon2D::buildConvexHull, matching the reference's "only convex shapes for now"; read water material PB2 params — required slot enables (1 env-above, 5 bump, 6 displace; missing → SKIP like the reference), optional slots (2 env-above-alt, 3/4 underwater envmaps, 7 diffuse/color map), scroll/scale params (fBumpUScale, fBumpVScale, fBumpUSpeed, fBumpVSpeed, fDisplace*), pool id, wave height factor, splash flag, realtime reflection + fresnel bias/scale/power (v15+ additions absent on v14 corpus materials → CWaterShape defaults fire cleanly), scene-envmap toggles; resolve textures via waterTextureFromTexmap() (BitmapTex → CTextureFile, NeL Multi Bitmap → CTextureMultiFile with 1-8 slot filenames from its own PB2); default transformation from decompMatrix(getLocalMatrix). NOT implemented this pass (documented open items in water_build.h): (a) setColorMapMat — the 2x3 affine from a chosen mesh triangle's world XY to UV+crop, involved reference math at export_mesh.cpp:1957-2043; only fires when slot 7 diffuse is enabled, most corpus water shapes don't use it; (b) CTextureBlend for env-map pairs (day/night blend) — reference builds one when slot 2 is present, we ship the primary only. Corpus impact: exported 2609 → 2828 (+219), water skips 221 → 1 (single file failing on missing required maps, matching reference behavior of returning NULL); differ 502 → 721 (+219 water shapes producing structurally-correct CWaterShape output but not yet byte-identical against v4-era references — output is stream v7, which adds ~14 bytes of fresnel/env-calc-reflectivity fields on top of v4; the byte-identity gap on these 219 files is dominated by the missing color-map + envmap-blend paths above, and by the shape file's v4-vs-v7 stream-version difference).
Corpus T3 state at end of session (shape_corpus.py --all -j 16): 2828 exported (was 2596; +232 = 13 EditablePoly + 219 water): 0 byte-identical + 771 float-noise-eq (unchanged from §10y) + 721 differ (was 489, +232 from the two new produce paths) + 72 mapext-bucketed + 982 lightmap-VERIFIED + 281 lightmap-diff + 1 no-ref; 762 reference shapes not produced (was 994, −232). Remaining skip classes: skinned 654 (the biggest single remaining not-produced class, unchanged), remanence 82, mesh-eval 14, flare 13, interface-mesh 9, water 1. Material anim (.anim) tier unchanged. T1/T2 stay 8632/8632 (this session touched no library-side classes — geometry-buffer accessors + new export-side code only).
Coverage moved from 72.7% → 78.8% of the reference corpus produced; the entire coverage frontier past skinned meshes is now either exported (water, multilod, EditablePoly, parametric prims — the M2 sweep) or in the small M2-tail bucket (remanence + flare + interface = 104 files).
Water byte-identity progression, same session — 3 rounds after landing the coverage path:
CTextureBlend(day, night) wrapping both the primary and secondary env texmaps; my initial ship built a plain CTextureFile from slot 1 alone, ~125 bytes short of reference. Rule from export_mesh.cpp:1879-1911: whenever the SECONDARY envmap texmap is present (slot 2's tTexture_2 reference), the reference exporter wraps BOTH slots in a CTextureBlend regardless of the enable-slot flag — this is what drives day/night blending via CWaterPoolManager::setBlend. Applied to both above- and under-water pairs. Independently: reference-era exports are CWaterShape stream version 4 (2004 plugin), the current in-tree write path emits 7 — v5-v7 add RealtimeReflection + fresnel bias/scale/power + EnvMapCalcReflectivity at the tail (14 bytes total). Patched the version byte + truncated the trailing 14 bytes on write, same precedent as the CMeshBase v10→v9 patch already in main.cpp (the version-byte offset calc differs — CMeshBase-derived shapes have their own class version byte AND the base's, so +1; CWaterShape is a plain IShape, so no +1). After round 1: OUR tr_water_00.shape 587 → 698 bytes, exact size match against reference; byte diffs 111 → 35, remaining diffs concentrated in HeightMap scale/speed + a small tail-bool region.fBumpU/VScale/Speed, fDisplace*, fWaterHeightFactor, iWaterPoolID) were the STANDARD NeL material's later params, not the water script's earlier ones. A PMB_WATER_DUMP env-gated dump against WaterBassinA02 (in watertrykerwhole.max) pinned the actual IDs empirically: fBumpU/VScale/Speed = 0x09..0x0c, fDisplaceU/VScale/Speed = 0x0d..0x10, fWaterHeightFactor = 0x11, iWaterPoolID = 0x12 — a distinct water-script prefix on top of the standard material's block. Tail-bool region: reference has splash=true, UsesSceneWaterEnvMap[0/1]={false,false}; ctor default splash=true matches, setUseSceneWaterEnvMap(0/1, false) matches explicitly. The name-to-id mapping for bEnableWaterSplash and bWaterUseSceneEnvMap{Above,Under} remains unmapped in-code — my initial guesses (0x19/0x1a/0x1b) turned out to be iBlendSrcFunc/iBlendDestFunc from the standard material's block layout that this material also carries. After round 2: byte diffs 35 → 3.0x29d, 0x2a1, 0x2a5 — the MSBs of DefaultRotQuat's x/y/z components (reference has the float sign bit set on all three; ours has them at 0.0). Suggests the reference exporter's decompMatrix path on water nodes produces a specific non-identity rotation the current-tree's does not — same headroom class as the CMesh DefaultRotQuat sign-flip §10i already documents, plausibly the same double-cover-boundary code path (w ≈ 0 tips whole-quat sign). Documented open item; water sits in a persistent FLOATEQ-adjacent tier after this session — material params correct, tail bools correct, texture structure correct, size match, only the 3-byte quat-MSB residual. The corpus's compareShapesFields path doesn't classify CWaterShape (only CMesh field-walks currently), so this doesn't move the DIFF-vs-FLOATEQ bucket yet — a lightweight CWaterShape field-walk in that compare would promote the corpus's ~219 water shapes to FLOATEQ.Handoff — the open items after this session, ranked by remaining corpus impact:
0x2394/0x2396 layout; need the Part C <PolyObject base data> MNMesh sub-container decode (Part C explicitly leaves this "out of scope" for the current format-reference).obj:0000000a, obj:00001040, obj:00001065. Identify each via maxole.py on a source .max from the reported class-id list, then bucket per (a) simple mesh-eval extension, (b) parametric prim family expansion, (c) genuine shape-family class the exporter shouldn't touch. The design-doc §10y coverage table's mesh-eval bucket entry was already down from a much larger baseline post-M2; these residuals may be non-shape carriers.mod:40c7005e, 110 uses) — our own plugin (plugin_max/nel_vertex_tree_paint/). Local-data storage layout: chunk 0x0100 = version int, 0x0120 = int32 numColors + numColors × COLORREF per-vertex colors. Applies as VERTCOLOR_CHANNEL fill on the evaluated mesh, so a headless application reads the mod-app 0x2500 slot → find where SaveLocalData chunks land (likely inside 0x2512 → 0x4000 per the framework wrapping, analog to Edit Mesh's MDELTA_CHUNK but with the modifier's own chunk ids) → apply to the SEvalMesh's map-channel-0 vert list. Requires empirical verification against a corpus file (dump via maxole.py a file where the modifier is present, cross-check the actual sub-chunk layout).Follow-up on §10z's handoff (flare 13 files unclosed, water DefaultRotQuat 3-byte residual sitting in the DIFF bucket for the ~219 water shapes). Three items landed, in decreasing order of remaining corpus impact after §10z:
CFlareShape path — closes the 13-file flare skip class. Replicates CExportNel::buildFlare (plugin_max/nel_mesh_lib/export_flare.cpp) into pipeline_max_export_shape/flare_build.{h,cpp} (FLAREBUILD namespace). The nel_flare scripted-plugin (plugin_max/scripts/startup/nel_flare.ms, ClassId (0x4e913532, 0x3c2f2307), extends Sphere) declares 57 params without explicit id: tags in MAXScript, so param IDs equal declaration order (same convention pipeline_max_export_pacs_prim/§K.1 already validated for nel_pacs_box/cyl). IDs pinned per corpus dump against zo_cn_mairie.max (7 flare PB2 objects, every id/value matched the parameter list): 0-9 texFileName0-9, 10-19 flareUsed0-9, 20-29 size0-9, 30-39 pos0-9, 40 ColorParam, 41 PersistenceParam, 42 Spacing, 43 AttenuationRange, 44 Attenuable, 45 FirstFlareKeepSize, 46 HasDazzle, 47 DazzleColor, 48 DazzleAttenuationRange, 49 MaxViewDist, 50 MaxViewDistRatio, 51 occlusionTestMesh, 52 occlusionTestMeshInheritScaleRot, 53 sizeDisappear, 54 angleDisappear, 55 scaleWhenDisappear, 56 lookAtMode. Color conversion follows the reference verbatim ((uint)(255.f * col.x), plain truncation — NOT the material_build path's rounded +0.5 convertColor which lands on different byte-boundary endpoints). DefaultPos = the local matrix translation (decompMatrix(getLocalMatrix) translation row); rotation/scale not exposed on CFlareShape (the flare is a point-emitter). Reference nlwarning "FAILED CFlareShape Spacing" reproduced. Corpus check: flare_preoder_item.shape, zo_flare.shape, zo_bt_mon_flare_couloir.shape, gen_autel_flare04.shape all byte-identical against ~/pipeline_export/*/shape_not_optimized references on first sample.
setDistMax scoping fix — surfaced by the flare implementation, real corpus-wide bug (matters for water/remanence/wavemaker/particle-system too, whenever those land). pipeline_max_export_shape/main.cpp's exportFile called shape->setDistMax(getScriptAppDataFloat(node, NEL3D_APPDATA_LOD_DIST_MAX, 1000.f)) UNIFORMLY on every returned shape. The reference CExportNel::buildShape (export_mesh.cpp:466-471) gates its setDistMax on !multiLodObject && buildLods INSIDE the mesh branch AFTER the special-shape early returns — buildFlare/buildWaterShape/buildRemanence/buildWaveMakerShape/buildParticleSystem all return before that block, so the reference NEVER sets DistMax on flare/water/remanence/wavemaker/PS shapes: they keep their ctor default (CFlareShape=1000). Our uniform call picked up whatever LOD_DIST_MAX appdata was authored on the flare node (zorai flares carry "300" and "100" strings): zo_flare.shape differed by 2 bytes at offset 0x117-0x118 (300.0 vs ref 1000.0). Moved setDistMax into the mesh/multi-lod branch of buildShapeForNode right before return meshBase, matching the reference's own scoping. Water shapes were unaffected in practice (the tested water sources carry no LOD_DIST_MAX appdata or carry 1000, so the previous uniform call was a coincidental match), but the bug was latent for every water file with a nonzero LOD_DIST_MAX authored on the node — the fix hardens water byte-identity against per-node appdata that may appear on other corpus samples.
CWaterShape field walk — promotes ~219 water shapes from DIFF to FLOATEQ. §10z round 3 closed the water byte-identity gap on every corpus water shape down to a 3-byte DefaultRotQuat MSB residual (the reference's decompMatrix lands on (+x,+y,+z,-w) while our current-tree walkNode lands on the negated representative — same double-cover boundary the CMesh path already documents), but compareShapesFields routed CWaterShape through the "unclassified fall-through" that raises verdict 2 unconditionally, so every water-differ file stayed in the DIFF bucket even after the structural fix landed. Added compareWaterShape walking the default transform (DefaultPos/RotQuat/Scale, double-cover aware on the quat, float-noise on pos/scale — same tiers CMeshBase already uses) plus the water-specific fields (WaterPoolID, WaveHeightFactor, splash/scene-envmap flags, HeightMap scale/speed pairs, polygon vertices — float-noise tolerated) plus the textures (EnvMap[0/1], HeightMap[0/1], ColorMap) via a new compareTexture helper that handles CTextureFile (path, case-insensitive), CTextureMultiFile (per-slot path), and CTextureBlend (recursive on its two blend inputs). Result: tr_water_00 (size 698 = ref exact, byte diff at 0x29d) now returns VERDICT FLOATEQ; spot-checked byte-exact/FLOATEQ on tr_water_00/01/02, water01, waterbassina01, watercanopee01. watermarais01 stays DIFF (poly vertices differ by ~344 — a real polygon-source difference from the mesh evaluation, unrelated to the quat class this walk addresses).
Corpus T3 state after this session (shape_corpus.py --all -j 16 measured): T1/T2 corpus 2850/2850 unchanged; T3 exported 2828 → 2841 (+13 flare shapes closing the class); byte-identical 0 → 12 (flare shapes); float-noise-eq 771 → 972 (+201, the water shapes promoted via the compareWaterShape field walk); differ 721 → 521 (−200, water promotions net of the one flare DIFF discussed below); mapext-bucketed 72; lightmap-verified 982 → 983; lightmap-diff 281 → 280; no-ref 1; not-produced 762 → 749 (−13 flare; skip classes: skinned=654 unchanged, remanence=82 unchanged, mesh-eval=14 unchanged, flare=13→0, interface-mesh=9 unchanged, water=1 unchanged). Material anim (.anim): 106 byte-identical. Coverage moved from 78.8% → 79.2% of the reference corpus produced.
One flare in the DIFF bucket — reference-era class: gen_autel_flare04.shape (from stuff/generique/decors/constructions/gen_bt_autel_kami.max) our output is byte-identical (293 bytes) to ~/pipeline_export/ecosystems/desert/shape_not_optimized/gen_autel_flare04.shape but differs by a single byte at offset 0x2d (00 vs 01) from ~/pipeline_export/common/construction/shape_not_optimized/gen_autel_flare04.shape — the common/construction reference and the ecosystems/desert reference differ from each other at that same byte, and our output matches the newer of the two. Same class as the §9 T3 "reference-era mismatches" documented behavior: the max source is authoritative, the ecosystems/desert reference was regenerated after the common/construction one, both are valid references, our exporter reproduces the newer. Not a bug in the flare decode; the corpus tester's per-project lookup picks the older common/construction reference first, so this one file counts as DIFF against that project's ref while being byte-identical against the desert re-export. The remaining coverage frontier is now cleanly bucketed: skinned 654 (biggest single class, needs undocumented Physique/Skin modifier-app decode — untouched here), remanence 82 (needs Max SplineShape decode, undocumented in max_geometry_formats.md), interface-mesh 9 (needs cross-.max interface-file loading + border normal welding), mesh-eval 14 (unknown object classes, may be non-shape carriers), water 1 (single file with missing required maps, matches reference behavior of returning NULL).
Handoff — the open items after this session, unchanged priority:
max_geometry_formats.md (Part L covers Edit Mesh's MDELTA_CHUNK; no analog for the skinning modifier-app slot yet). Reference export_skinning.cpp:444+ uses Max SDK ISkin/IPhysiqueExport interfaces — no headless equivalent. Path forward: (a) locate a small skinned corpus file, dump its OSM Derived wrapper's 0x2500 payload for the Skin/Physique modifier, (b) generate a Max 9 differential dataset (two variants of a minimal skinned mesh with a single vertex's bone weight changed — same channel as ~/biped_dataset etc.) and byte-diff to pin the per-vertex weight layout, (c) integrate with pipeline_max_export_common/biped_rig's bone-name/order infrastructure, (d) wire CMeshMRMSkinned::build with per-vertex weights + bone name array.decompMatrix sign-boundary. Same class as the CMesh DefaultRotQuat sign-flip (§10i). Unlikely to be fixed without the VS2008/x87 reference-linked SDK object code (§2b handoff).buildRemanence (export_remanence.cpp:43) samples so->InterpPiece3D(time, 0, k, u) — bezier interpolation on the spline; without Max SDK, need to decode the spline's control point + interpolation-type storage from scratch. Substantial task on its own; may share machinery with a broader spline shape / editable spline support.BuildMeshInterfaces in export_mesh_interface.cpp) + border normal welding within a distance threshold. Reference at export_mesh_interface.cpp:695+.Continued from §10x's four open items on pipeline_max_export_cmb. Two fixes landed, plus the ligo tier changed status from informational to gated (with a documented budget) — same as its .ig counterpart. Corpus state at end: T1/T2 16/16 clean, T3 direct 10 byte-identical + 3 float-eq + 3 differ (unchanged in count, but the underlying quality of the diffs moved another notch — see below); T3 ligo 32 byte-identical + 84 float-eq + 105 differ + 0 export failures (from §10v's 11+15+22+6=54 and §10x's 30+83+41+16=170; the +67 diff files vs §10x is because the 16 formerly-failing-outright bricks now succeed and land in a diff bucket that the corpus was already measuring — the 16 export failures went to 0, so the total measured igs jumped from 170 to 221). ig/shape corpora re-verified byte-identical (ig pipeline_max_ig_corpus T1/T2 116/116 + T3 54/54 field-exact + 40/40 processed; shape pipeline_max_shape_corpus T1/T2 8632/8632, T3 12+972+521+72+982+281+1 = 2841 unchanged from §10z-bis's landing modulo a single lightmap file swap between verified/diff on the tolerance boundary of the new 0x0210 remap — no structural regression).
0x0210 was misclassified as "created map-1 texture-vertex faces" — it's the FACE-VERTEX REMAP table (modern equivalent of legacy TOPO_FACEMAP_CHUNK 0x2780, §L.5). §10x had catalogued 0x0210 as "created faces variant B (20-byte stride)" based on an inferred parallel with 0x0208 (created base faces, 24-byte stride, with srcTag): both looked like face records, both were shipped by the same modifier. The design-doc §L.2 table carried that classification. The corpus refuted it via fy_hall_reunion's single-face-index-remap open item — --gate-t3's T3 DIFF FY_hall_reunion.cmb output showed face 18 as OURS=(54,55,53) vs REF=(76,81,74), and dumping the raw 0x0210 payload of the Edit Mesh app with 16 created verts showed exactly 3 entries: [18, 0x7, 76, 81, 74], [19, 0x0, 1, UNDEF, 72], [55, 0x7, 72, 73, 80] — reading the 20 bytes as (uint32 faceIdx, uint32 applyMask, uint32 v[3]) produced (a) an exact match for the reference's face 18 replacement, (b) a no-op remap on face 19 which the same Edit Mesh's 0x0270 deleted-face bit array deletes, (c) a remap on face 55 which the same bit array also deletes. Both structural facts (the mask semantics and the "no-op remap on a soon-deleted face" pattern) generalise across the corpus: sweep of 445 files / 113 chunks / 2881 entries showed every observed mask in {0..7} (0/1/2/3/4/5/6/7 counts: 4/419/309/1177/414/152/107/299 — bit-per-corner apply, most common is mask=3 = "edge diagonal flip") and every faceIdx < 65536 (real face indices, not arbitrary offsets). No entry appeared with the map-1-face v[3] shape (small first index that could be a matID or SmGroup) — the earlier "created-map-1-face" reading fit zero corpus entries.
The read structure SFaceVertRemap { Index, ApplyMask, V[3] } and per-corner accessor applyCorner(c) landed in pipeline_max_export_common/edit_mesh_mod.h, and the shared EDITMESH::applyEdits gained a remap pass between moves and face-deletes (per-corner rewrite of faces[Index].V[c] = V[c] guarded on applyMask & (1<<c); corners not covered by mask carry writer-uninit bytes and are ignored). Order: remap → deletes → append created verts → append created faces (0x0208 only; 0x0210 is now a per-input-face remap, not an appended-face record) → vert-deletes. Cmb's FY_hall_reunion face-index match went 247/248 → 248/248 (100%); the residual on that file (the 16 created-vert positions offset by ~10 units and the size-scale variant Zo_bt_hall_Reunion_vitrine's 25 verts + 221 face diffs) is now understood as an INDEPENDENT residual and stays open — 0x2510 mod-context TM applied to created verts remains the unproven candidate for that class. Zone/shape/ig also share the same EDITMESH library, so this remap now applies to their evaluation paths too — ig T3 unchanged (its cluster-containment link test operates on vertices, and a face-vertex-index change doesn't move which vertices are in the mesh), shape T3 shifted one lightmap-tier file between verified and diff on the tolerance boundary (a real face-index change that moved the compare's index-content match by a hair), no structural regression.
Cmb now handles EditablePoly and parametric primitives as base object classes. §10v/§10x's cmb only recognised EditableMesh's tri-buffer path (CGeomBuffers::triVertices()/triFaces()); when the resolved base object was an EditablePoly (polyVertices()/polyFaces(), needs CGeomObject::triangulatePolyFace — shape's mesh_eval already drives this) or a parametric primitive (Box/Cylinder/Sphere/Plane with no CGeomBuffers at all, needs PRIMMESH::buildParametricMesh — shape+ig share this via the common library), extractObjectMesh returned "no GeomBuffers chunk" and the whole node dropped out of its cmb group. Ligo brick sweep of zonematerial-foret-18_village_a.max alone showed 4 skipped ma_annexe_col_int XRef instances hitting this — one collision node per instance dropped from the merged brick .cmb. Now cmb dispatches by CGeomBuffers availability first (EditableMesh vs EditablePoly), then by base object class ID for the parametric-primitive fallback; the modifier-stack pass (Edit Mesh / Mirror / UVW Map / warn-unhandled) is unchanged and runs on whichever base extractor produced the initial mesh. Ligo corpus warnings dropped from ~230 (§10x baseline) to 47 across all 1201 bricks — the residual splits as 35 "base object is not a CGeomObject at all" (XRef sources that resolve to non-Geom superclasses — e.g. Shape/SplineShape 0x40, or the classes we can enumerate but not yet decode to a mesh) and 12 with a single unregistered scripted-plugin class ID (0x51db4f2f, 0x1c596b1a) on Matis buildings (found in-file but not in the current brick's ClassDirectory3 — this class lives in the XRef source's directory, not the brick's, and the class isn't registered in CBuiltin or its plugin siblings yet).
Ligo cmb tier is GATED, not informational — per user direction. nel_ligo_export.ms calls NelExportCollision per brick exactly the way it calls NelExportInstanceGroup (compare §10g-bis for the ig side); the output brick-level .cmb files land in LigoEcosystemCmbExportDirectory and become land_exporter's RefCMBDir input for the placement-copy step that feeds rbank's "Build rbank indoor" (which builds the shipped .rbank/.gr/.lr). So the ligo tier's fidelity matters exactly as much as the standalone tier's. cmb_corpus.py gained a LIGO_DIFF_BUDGET = 105 (current diff count) that fails --gate-t3 on regression past that count, same shape as ligo_ig_corpus.py's DIFF_BUDGET. Tighten as diffs close.
Corpus state at end of session:
pipeline_max_cmb_corpus T1/T2 16/16, T3 10 byte-identical + 3 float-eq + 3 differ (DIRECT_DIFF_BUDGET=3). FY_hall_reunion's underlying face-index match is now 248/248 (was 247/248 in §10x, 229/248 in §10v); its residual is the 16-vert positional offset alone. Zo_bt_hall_Reunion_vitrine face-index similarly improved. Zo_bt_Hall_Conseil's 80/80-vert X/Z divergence with Y preserved is unchanged (open on its own terms since §10v — the diagnosed rotation-about-Y class, precision ruled out via VS2008/x87).LIGO_DIFF_BUDGET=105; up from §10x's 30+83+41+16 = 170 measured with 16 outright failures, the +2 byte-identical and +1 float-eq are the face-remap fix landing on ligo bricks, the +67 diff is the 16 formerly-failing bricks now producing content and the newly-eligible XRef sources for EditablePoly/prim shapes producing more igs — net coverage up materially).Open items after this session (ranked by remaining corpus impact):
LIGO_DIFF_BUDGET=105. Root-cause classes: (a) 35 "base object is not CGeomObject" — XRef sources whose evaluated base is a Shape/SplineShape (superclass 0x40) or an unknown class the cmb path can't yet build a mesh for; (b) 12 unregistered scripted plugin (0x51db4f2f, 0x1c596b1a) on Matis buildings — needs the class registered somewhere (probably a scripted plugin the corpus source .max files define via nel_scripted_plugins.ms-style DLL entries); (c) vertex-position diffs of the same class as the direct tier's FY_hall_reunion (candidate 0x2510 mod-context TM applied to created verts, unproven); (d) the shared ig/cmb village_nb_0x/selection-order class documented on the same brick set.FY_hall_reunion 16 created-vert positions offset by ~10 units — unchanged from §10x. Candidate: 0x2510 mod-context TM applies to created-vert positions before the object→world transform (the Edit Mesh app with 16 created verts has 0x2510 = identity+tiny in fy_hall_reunion, but multiple Edit Mesh modifiers stack on the same node in this file — the mod-context TM of a LOWER modifier in the stack may need to be applied to the 0x0130 stored positions). Needs a per-node modifier-stack probe with 0x2510 dumped per slot to confirm.Zo_bt_Hall_Conseil vertex offset — unchanged from §10v/§10x. Y exact, X/Z diverge; rotation-about-Y suspected, precision confirmed non-causal (§10v). Open on its own separate terms.(0x51db4f2f, 0x1c596b1a) — 12 corpus files carry this on Matis collision meshes. The class lives in some .max file's ClassDirectory3 as a scripted plugin, and its base-object semantics are unknown until located and decoded (probably a NeL-side plugin like nel_pacs_box-family).max_geometry_formats.md § L.2 (Part L) — the 0x0210 = created MAP-1 TEXTURE-VERTEX faces row is now known to be wrong; the corpus fits face-vertex remap (per-face per-corner index rewrites, mask + v[3]) exactly and doesn't fit the created-faces reading at all. Update the wiki page accordingly (this session note governs precedence per §0a rule until the format-reference page is updated).Session goal was the standing §10z-bis handoff item — pushing shape export coverage further, especially into the sole big frontier (Physique/Skin skinning, 654 files). The undocumented Physique per-node payload storage turned out to be too big a leap for one session without a differential dataset (which needs Max SDK access), but the session characterized the mod-app payload structure end-to-end at the byte level (documented as max_geometry_formats.md Part M) so a future decode session begins with a written baseline instead of a cold re-derivation. Three smaller-scope items landed as hardening around that.
Physique/Skin detection retargeted from display-name string equality to concrete (ClassId, SuperClassId) tuples. §10z-bis's detection was if (disp == "Physique" || disp == "Skin") — fragile because Max shares ClassId (0x100, 0) across four unrelated classes (Placement 0xc20, Output 0xc40, Physique 0x810, Shadow Map 0x10d0 — the SuperClassId is the discriminator; corpus-verified via ClassDirectory3 dump of fyros/tryker/matis armor .max files). Replaced with SCENELIB::CLASSID_PHYSIQUE(0x100, 0) + SCLASS_OSMODIFIER(0x810) tuple match and SCENELIB::CLASSID_SKIN(0x0095c6a3, 0x00015666) (from Max SDK iskin.h). Corpus impact: the SKIPCLASS bucket was split into skinned-physique (653) and skinned-skin (0 — every corpus skinning modifier is Physique; consistent with the Max 3-era authored corpus), and one file was correctly re-routed from the string-mismatched skinned bucket to the interface-mesh check that catches it later in the dispatch (interface-mesh 9 → 10). Not-produced total unchanged 749.
Physique per-node payload structure characterized at byte level, documented as Part M. New env-gated PMB_SKIN_DUMP=1 diagnostic in pipeline_max_export_shape/main.cpp dumps the Physique/Skin mod-app 0x2500 payload as a recursive chunk-tree with hex previews (first 128 bytes per leaf). Applied to fy_hof_armor01_pantabottes (523 vertices, one Physique modifier) it pinned the full structure — see max_geometry_formats.md Part M for the byte-level spec. Highlights:
0x2510 mod-context TM (52B) + 0x2511 ctx bbox (24B) + 0x2512 payload container + 0x2513 4-byte trailing state. Physique's payload container 0x2512 contains one child 0x2504 (Edit Mesh has 0x2512 → 0x4000 MDELTA_CHUNK instead — different).0x2504: fixed 4-chunk header (0x2501 12B version+flags, 0x2502 deformable-space transform container, 0x2509 17B global params including the 313.0f sentinel float and two 0xffffffff markers, 0x2802 reference-pose snapshot byte-identical to 0x2502) followed by numVertices instances of 0x2506 container → 0x0989 leaf.0x0989 per-vertex record: uint32 numBones (2 or 3 on the corpus samples) + numBones × 20 bytes per-bone data. Per-bone layout: {uint32 boneRef, Point3 offset, float weight/rigidity}.Open decode blockers documented (Part M §M.3). The boneRef field takes distinct high-byte values (0xff, 0xfe, 0xfd, 0xad, 0xb3 with low three bytes uniformly 0xffffff) that don't cleanly interpret as scene-storage indices — probable Physique-internal enum + bit-packed chain index; needs a Max 9 differential dataset (two variants of a minimal skinned mesh with a single vertex's bone reassigned) to pin the semantic. The weight field also doesn't cleanly sum to 1 across records (2-bone case has both = 1.0 in some records; 3-bone case sums to 1.4) — likely a Physique rigidity/blend factor, not the final normalized skinning weight; the reference plugin's IPhysiqueExport::GetWeight path internally computes the final weights via the rigidity blend (see plugin_max/nel_mesh_lib/export_skinning.cpp:658-799), so replicating that math needs the reference-side bone transforms too. Bone-name array location on the modifier scene object (as opposed to the mod-app) is unlocated. If final normalized weights aren't derivable from the stored payload alone, byte-identity for CMeshMRMSkinned is closed for Physique and coverage-only routing (placeholder root-bone weights, structural mesh export) becomes the pragmatic fallback.
Shape-superclass base objects routed to a distinct warn bucket. §10z's mesh-eval skip bucket lumped together GENUINE mesh-decode failures with Shape-superclass (0x40) base objects — the latter are SplineShape (ClassId 0xa), Line (0x1040), Rectangle (0x1065), all splines rather than meshes, and would need CSegRemanence or a dedicated spline decode. mesh_eval.cpp now emits a distinct WARNING: shape-class base object ... line for these, and shape_corpus.py counts them into a shape:* warn bucket. mesh-eval skip count is unchanged (14) since the SKIP still fires for each (there's no downstream path yet), but the harness triage is cleaner for the follow-up session — the 14 mesh-eval SKIPs split into 7 SplineShape + 2 Line + 5 Rectangle across fy_cn_bridge_work.max / ma_hom_armor04.max / fy_cn_mairie.max and companions.
Corpus T3 state at end of session (shape_corpus.py --all -j 16 measured, unchanged coverage counts — hardening + docs, not new coverage): T1/T2 corpus 8632/8632 unchanged; T3 exported 2841 unchanged (12 byte-identical + 972 float-noise-eq + 521 differ + 72 mapext + 982 lightmap-verified + 281 lightmap-diff + 1 no-ref); not-produced 749 unchanged. Skip-class re-split: skinned-physique=653 (was skinned=654), remanence=82 unchanged, mesh-eval=14 unchanged, interface-mesh=10 (was 9 — the one file correctly re-routed from Physique/Skin string mismatch), water=1 unchanged. Coverage 79.2% unchanged. Material anim (.anim) 650 byte-identical unchanged.
Handoff — the open items after this session, priority-ranked:
~/biped_dataset, ~/prim_mesh_dataset, ~/biped_anim_dataset*. PMB_SKIN_DUMP=1 is available for the follow-up decode work.PMB_SKIN_DUMP diagnostic will characterize it.max_geometry_formats.md).buildRemanence fallback if the reference actually produces .shape files for them; more likely they're accepted-then-ignored by the reference at some level.Fixed (2026-07-05, biped roundtrip-coherency session — these two are why T2 jumped from 2/169 to 168/169 on the biped corpus):
CAppData::build iterated m_Entries (a std::map<TKey, CAppDataEntry*>), silently re-sorting AppData entries by (ClassId,SuperClassId,SubId) on every rebuildNode object hit it. Fixed by adding m_EntryOrder (a std::vector<CAppDataEntry*> tracking parse/creation order) that build() iterates instead of the map; m_Entries remains the map used for keyed lookup (get/getOrCreate/erase), now kept in sync with m_EntryOrder at every mutation point.CSceneClassContainer::createChunkById ignored the container bool (the file's own container-bit for that chunk) and always returned a CSceneClassCSceneClass is a CStorageContainer, whose isContainer() is hardcoded true, so a scene-object slot that the source file stored as a literal 0-byte leaf (container bit unset — observed on scattered class instances that carry no parseable data at all) got its container bit silently flipped to true on rebuild. Undetectable at parse time (both interpretations consume 0 bytes, so no exception fires) and invisible in per-object content diffs (an empty container and an empty leaf both serialize to 0 bytes) — only a whole-stream byte comparison catches it, which is exactly why it survived until the T2 corpus gate. Fixed generally, not just for scene objects: IStorageObject gained a virtual bool writeAsContainer() const (default { return isContainer(); }), and CStorageContainer::serial(CStorageChunks&)'s write loop now calls writeAsContainer() instead of isContainer() for the wrapper's container bit. CSceneClass overrides it via a new m_ReadAsLeaf flag, set by CSceneClassContainer::createChunkById whenever the file's container param is false, and exposed for other classes that might need the same escape hatch via setReadAsLeaf().Fixed (2026-07-05, in the same session that stood up the pipeline_max native build):
CStorageValue<std::string>/<ucstring>::getSize returned size as bool, never wrote the out-parametersize and returns true. Same bug class in CStorageArraySizePre<T>::getSize adding 4 to a bool returngetSize-relying code path.CSceneClass::putChunk inserted into m_OrphanedChunks with an iterator borrowed from m_Chunksm_Chunks.insert(...). Prior behavior "worked" because std::list splices the node into the iterator's owning list, but left both lists' cached sizes inconsistent, which quietly skewed the disown sanity check m_Chunks.size() < m_OrphanedChunks.size() — reliably surfaces as an "Not built" false alarm during parse→build→disown on real corpus files (observed on ge_mission_eolienne_tr.max, now green after the fix). T2 corpus must still re-verify byte fidelity once the harness is up — the byte-writing path was already correct via the splice; the size counters were the load-bearing lie.CSceneClass::getChunkValue nlerror("… 0x%x …") with no format argument(uint32)id now.CTrackViewNode::parse copy-paste on 0x0150 chunk checkif (m_Empty0150) nlassert(m_Empty0140->Value.empty())) — now derefs m_Empty0150->Value.empty(). Latent nullptr crash if 0x0150 appears without 0x0140.CStorageRaw::serial + CStorageValue<string>/<ucstring>::serial + CConfigScriptMetaString::serial took &Value[0] on possibly-empty containersif (Value.empty()) return; (or wrapped for the null-terminated case). serialBuffer no-ops on len 0 so it was benign; UB by the letter of the standard.CSceneClassUnknownDesc::create had no return statement after nlassert(false)return NULL;. Path is unreachable (unknowns go through createUnknown on the superclass desc) but a release build would fall off the function end.Fixed (2026-07-05, biped corpus-completion session):
0x1190 ("Depth of Field (mental ray)") unregistered, nlerror-aborting the whole process*_fp.max first-person-hand file in the corpus carries this superclass (a camera/render effect, irrelevant to skeleton export) and crashed pipeline_max_export_skel outright. Fixed the same way as every other unimplemented superclass observed in the corpus: added CCameraEffectSuperClassDesc (CSuperClassDescUnknown<CReferenceTarget, 0x00001190>) to CBuiltin::registerClasses (builtin.cpp) so it falls through to the standard pass-through unknown instead of erroring. No behavior change for any file that doesn't carry this superclass.tr_mo_estrasson.max) and, it turned out, a ~15% T1/T2 failure rate across the wider ~8.6k-file corpus (measured via two random samples, 400 + 800 files, against a clean-after-fix 0/1200). Root cause was exactly as diagnosed previously: CStorageContainer::m_Was64Bit (added by the earlier "64-bit chunks not writable" fix, below) is a single flag per container, so any container whose own direct children were a genuine mix of 32-bit and 64-bit headers wrote every child at one uniform width. Fixed with per-chunk width tracking: IStorageObject gained a tri-state wasRead64BitChunk()/wasRead64BitChunkKnown() pair (set by CStorageContainer::serial(CStorageChunks&) right after each child chunk is entered on read); the write loop uses the per-object value when known, and falls back to the containing container's own m_Was64Bit aggregate only for chunks a typed class rebuilds from scratch during build() (e.g. CNodeImpl's version/parent/name, which have no "original width" of their own since they're freshly constructed, not the object that was actually read). CStorageChunks::enterChunk(id, container, as64Bit) takes the width as an explicit parameter now instead of reading its own sticky m_Is64Bit flag on write. Verified: skel corpus T1/T2 169/169 (up from 168/169), plus two independent full-corpus samples (400 files seed 42, 800 files seed 7) fully clean where the same population measured ~15% failing before the fix — no regressions found in either sample.Open (accepted, or need work beyond mechanical fix):
CSceneClassContainer::parse assumes the builtin scene is the last chunk (m_StorageObjectByIndex[size()-1], scene.cpp:153) and hard-asserts the dynamic cast. Holds for the corpus; will fire on files where it doesn't. Acceptable as an assert, but know it's there.CStorageContainer gained a per-container m_Was64Bit flag set on read from CStorageChunks::is64Bit(); the top-level serial(stream, size) (and the gsf-path variant) round-trips it through CStorageChunks::set64Bit() on the write side. CStorageChunks::enterChunk/leaveChunk now emit either a 6-byte 32-bit header or a 14-byte 64-bit header per-chunk (HeaderSize field on CChunk tracks the width for size back-patching). Rule preserved: a file containing any 64-bit chunk header round-trips 64-bit; a file with none stays 32-bit.nlerror/nlassert = process abort throughout (several files deliberately #define nlwarning nlerror — "elevate warnings to errors for stricter reading"; keep that convention for new class code, it's what makes silent misparses impossible). The corpus harness, however, must run file-scoped: wrap per-file work so one bad file records a failure and the sweep continues (NeL assert/error behavior is configurable enough to throw instead of abort; that, or a child-process-per-file runner — decide once, at harness level, not by softening the library's checks).Follow-up on §10z-quat's Handoff item 6 (parametric-primitive coverage). ~/shape_export_dataset (Max 9 differential dataset generator pipeline_max_corpus_test/gen_shape_export_dataset.ms, produced same day) landed with per-primitive ground-truth mesh + face metadata for Box (1-seg + 3×2×2 seg), Cylinder, Sphere, Plane, plus the plain-mesh baseline plain_box. Cross-checking getFaceMatID/getFaceSmoothGroup per primitive class in that manifest against §10z's extractParametricPrimitive output — which assigned matId=0 and sg=1 UNIFORMLY to every face — surfaced the concrete corpus regression: Max's own Box primitive assigns a distinct (matId, sg) per face type, and any node whose material is a MultiMtl (the box01.shape corpus DIFF class, "materials: 1 vs 6") collapsed every triangle into a single rdrpass on material 0.
Per-primitive matId + sg tables (0-based matId = MAXScript getFaceMatID − 1; sg = raw smoothing-group bitmask, one bit per group). Corpus-validated GT counts (~/shape_export_dataset/manifest.txt face buckets, verified via python3 group-by):
| Prim | Face group | matId | sg |
|---|---|---|---|
| Box | bottom (z=0) | 1 | 0x02 |
| Box | top (z=h) | 0 | 0x04 |
| Box | -Y side | 4 | 0x08 |
| Box | +X side | 3 | 0x10 |
| Box | +Y side | 5 | 0x20 |
| Box | -X side | 2 | 0x40 |
| Cylinder | bottom cap | 1 | 0x01 |
| Cylinder | sides | 2 | 0x08 |
| Cylinder | top cap | 0 | 0x01 |
| Sphere | (uniform) | 1 | 0x01 |
| Plane | (uniform) | 0 | 0x01 |
PRIMMESH::SPrimTri (pipeline_max_export_common/parametric_mesh.h) grew from {V[3]} to {V[3], MatId, SmGroup} — additive; pipeline_max_export_cmb's parametric-primitive fallback (which fills its own MatId=0 and full edge-vis per its own collision-mesh conventions) is unaffected. PRIMMESH::buildParametricMesh (parametric_mesh.cpp) populates the new fields per prim class using the tables above — Box's perimeter-side iteration (-y, +x, +y, -x) matches the sideStart table order already in the generator, so the (matId, sg) tuple maps 1:1 by side index. pipeline_max_export_shape/mesh_eval.cpp::extractParametricPrimitive reads them into SEvalFace::Flags (matId packed at bits 16..31 via the existing MAX_FACE_MATID_SHIFT shift) and SEvalFace::SmGroup, keeping the low 3 bits of Flags at 0x7 for all-edges-visible.
Corpus impact (shape_corpus.py --all --t3 -j 12, full 2841-shape sweep, pre-fix baseline captured same session at 12 byte-identical + 972 float-noise-eq + 521 differ + 72 mapext + 982 lightmap-verified + 281 lightmap-diff + 1 no-ref = 2841 exported / 749 not produced):
| Metric | pre-M1 | post-M1 |
|---|---|---|
| byte-identical | 12 | 12 |
| float-noise-eq | 972 | 973 (+1) |
| differ | 521 | 520 (−1) |
| mapext-bucketed | 72 | 72 |
| lightmap-verified | 982 | 982 |
| lightmap-diff | 281 | 281 |
| no-ref | 1 | 1 |
| not-produced | 749 | 749 |
Small movement (+1 float-noise-eq, −1 differ) reflects the fact that the primitives-as-shape class is small (~37 nodes per §10y). The box01.shape corpus DIFF class specifically moved from "materials: 1 vs 6, VB: 8 vs 24 verts" to "VB: 24 vs 24 verts, rdrpass 0 index content differs, rdrpass 1 index content differs" — the mesh now has the correct topology and material bucketing (each face lands on its own rdrpass matching the reference's 6 sub-materials), the residual is index ordering + missing per-side UV mapping (see follow-up below). No regressions on any other class: mesh-eval/skinned-physique/remanence/interface-mesh/water unchanged at their §10z-quat counts, T1/T2 corpus 8632/8632 untouched (library-side classes unchanged), ig/cmb/zone re-verified via pipeline_max_ig_corpus/pipeline_max_cmb_corpus/pipeline_max_zone_corpus — cmb's parametric-primitive fallback fills its own MatId per collision-mesh convention so it consumes only V[3], ig has its own buildParametricMesh local to pipeline_max_export_ig/main.cpp and is not currently a PRIMMESH consumer.
Follow-up open (parametric-primitive UV mapping-coord generation, §10y handoff item unchanged): the residual box01 DIFF's VB value 2: 24 comps differ is the missing per-face UV generation (Box's mapCoords=true produces 12 UV verts per single-seg cube at (0,0),(1,0),(0,1),(1,1) per axis-pair with opposite faces sharing UV verts; multi-seg boxes produce more; cylinder/sphere have seam handling with duplicate UV verts). The GT for all four primitive types is in ~/shape_export_dataset/manifest.txt (primuv_box/primuv_box_multiseg/primuv_cyl/primuv_sphere/primuv_plane), same channel as this session's matId/sg fix; skipped for now because Cylinder + Sphere UV seam handling is materially larger than the matId table and would need its own separate corpus round to validate. Priority stays as documented in §10y — untextured/collision parametric primitives already match the reference, textured ones (a subset of the 37) will keep diffing on VB value 2 until the UV formulas land.
Dataset generator caveat: the gen_shape_export_dataset.ms UVW-Map cases (uvw_planar/cyl/sph/box/face/gizmo, 13 total) FAILED at authoring time with -- Unknown property: "gizmo" in Uvwmap:UVW Mapping — MAXScript in Max 9 SP2 exposes the gizmo differently than the script assumes. Those cases are absent from the manifest; UVW Map decode (76 corpus uses per §10y handoff) needs the generator's gizmo API path corrected before that class can be re-attempted with GT. All other dataset cases (physique, skin, unwrap, ffd, morph, prim-uv, interface, box-multimtl, plain-mesh, stduvgen, vertextreepaint) authored cleanly and are available for the follow-up decode rounds.
StdUVGen static UV transform — dataset staged, decode blocked pending matrix formula validation (2026-07-09, same session): the four stduvgen probes (stduvgen_baseline/_offset/_tile2x3/_angle45) were decoded via maxole.py scene-stream walking. The StdUVGen's OLDPBLOCK carries the U/V/W transform state in a fixed 13-param layout (each param a 0x0002 container with 0x0003 index + 0x0100 float value, per §K.2 shape):
| Param | Meaning | baseline | offset | tile2x3 | angle45 |
|---|---|---|---|---|---|
| 0 | U Offset | 0.0 | 0.25 | 0.0 | 0.0 |
| 1 | V Offset | 0.0 | 0.5 | 0.0 | 0.0 |
| 2 | U Tiling | 1.0 | 1.0 | 2.0 | 1.0 |
| 3 | V Tiling | 1.0 | 1.0 | 3.0 | 1.0 |
| 4 | U Angle | 0.0 | 0.0 | 0.0 | 0.0 |
| 5 | V Angle | 0.0 | 0.0 | 0.0 | 0.0 |
| 6 | W Angle | 0.0 | 0.0 | 0.0 | π/4 |
| 7-9 | Blur / BlurOffset / Noise-related | 1.0 / 1.0 / 1.0 | (constant) | (constant) | (constant) |
| 10 | int flag (=1 uniformly) | 1 | 1 | 1 | 1 |
| 11-12 | Noise Phase / Noise Level | 0.0 / 0.0 | (constant) | (constant) | (constant) |
The mapping is exact (each single-variable probe case moves only the param it targets, verified via full byte-diff against the baseline). The blocker on shipping the decode into the exporter's readUVGen + remap.UVMatrix = the reference's matrix composition order (Max SDK StdUVGen::GetUVTransform returns a Matrix3 from TransRotScale, but the NeL side calls a uvMatrix2NelUVMatrix conversion whose exact convention needs a corpus-reference cross-check on an animated waterfall — the reference bakes it into setUserTexMat, our path ships Identity, so the byte delta is a fixed offset per animated material, per §10z round 3). Once the matrix formula lands, the ~48-bytes-per-animated-material class (waterfalls et al) promotes from DIFF to FLOATEQ in one step. Foundation ready for that follow-up session.
Continued the §10z-quat Physique investigation (the 653-file frontier, still the sole big not-produced shape class). The session goal was to test whether the per-modifier bone table the M.3 open items said "needed a differential dataset to locate" could in fact be found by dumping the modifier SCENE OBJECT (the §10z-quat PMB_SKIN_DUMP only dumped the mod-app 0x2500, not the modifier). It can — and the primary bone-reference decode falls out of it for free. The two blockers a differential dataset is still needed for are now precisely scoped rather than open.
PMB_SKIN_DUMP extended to dump the modifier scene object (main.cpp): for the Physique modifier it now walks CReferenceMaker::nbReferences()/getReference(i) and prints each ref's class id / superclass / (for INode) bone NAME, plus the modifier's own raw chunk ids. --dump-skin <shape> added too: loads a built .shape and prints its bone-name list + the first 16 vertices' SkinWeights (via CMeshMRM[Geom]::getSkinWeights()/getBonesName()), so a candidate decode can be checked field-by-field against a reference shape without a full build.
The bone table IS the modifier's reference array. On fy_hof_armor01_pantabottes the Physique modifier carries 87 references, each a bone INode (Bip01 Pelvis, Bip01 Spine, … Bip01 R Toe0), with 16 NULL slots interspersed (Physique reserves one reference slot per link-tree position; the NULLs are COUNTED in the index — Bip01 Head is at index 6 because index 5 is NULL). This is max_geometry_formats Part M §M.3 item 1 ("locate the per-modifier bone table") and item 4 ("bone-name array source") — both SOLVED: the names come straight from the modifier refs, matched against the walked skeleton's INode*→boneId map exactly as the reference's buildSkinning does (skeletonShape.find(boneNode); BonesNames is the SKELETON's bone list reduced to the used subset by the mesh build's bone-use pass — the modifier refs only identify which bones each vertex uses).
boneRef = ~index (one's complement) — validated for primary/rigid links. The stored boneRef bytes are [XX, ff, ff, ff] (little-endian 0xFFFFFFXX = −1, −2, −3, −83, −77, …) which is ~index; boneIndex = ~boneRef resolves to 0, 1, 2, 76, 82, … and ref[index] is the bone. End-to-end check on the pantabottes mesh: boneRef −1→idx 0→Bip01 Pelvis, −2→idx 1→Bip01 Spine, −77→idx 76→Bip01 L Thigh, −83→idx 82→Bip01 R Thigh — anatomically exact for hip/pants geometry (a hip vertex influenced by pelvis + both thighs). So the M.2 "bit-pattern semantic" puzzle (which M.3 item 1 flagged for a differential dataset) is decoded for the common case by inspection: it is a one's-complement index, not a Physique enum.
Two blockers remain, both now scoped rather than open (Part M §M.3):
0x0000004d = 77), not ~index. These are Physique deformable cross-links — the stored per-vertex data is the DEFORMABLE link tree (each record is numBones × {boneRef, offset, weight} at 20 B/bone for the primary links, but cross-link records carry a different structure the uniform 20-byte read misaligns on). Cracking these needs the Max 9 differential dataset M.3 item 1(b) always asked for.Bip01 (COM) root-link discrepancy. The reference fy_hof_armor01_pantabottes.shape lists Bip01 as bone[0] with MANY rigid vertices, but Bip01 is NOT among the modifier's 87 references — those vertices are produced by the SDK's ConvertToRigid(TRUE) (called at line 636 of export_skinning.cpp) promoting deformable vertices to the rigid ROOT link. The reference's .shape SkinWeights come from IPhyBlendedRigidVertex::GetNode/GetWeight AFTER that conversion, so reproducing them headlessly means replaying ConvertToRigid's link-hierarchy logic — the same SDK object code that resolves GetNode. If that is not reproducible from stored bytes alone, byte-identity is closed for Physique and coverage-only routing (structural mesh + best-effort/placeholder root-bone weights) is the documented pragmatic fallback.Useful negative result along the way: the reference buildSkinning does NOT use the per-bone offset vectors for the .shape's SkinWeights — only GetNode + GetWeight. The offsets feed the bind pose via GetInitNodeTM (addSkeletonBindPos), which the skeleton export already supplies. So a headless decode that reaches the rigid/blended form needs only boneRef→bone, weight, and the top-4+normalize — the offsets are skippable for the weight array (they matter only if/when a headless bind-pose path is wanted).
Corpus state unchanged (this session added diagnostics + documentation, no production path): T1/T2 corpus 8632/8632; T3 exported 2841, not-produced 749 (skinned-physique=653 still the biggest single class). The findings retires two of Part M's four open items and gives the eventual differential-dataset session a written baseline instead of the cold start §10z-quat handed it.
Picked up mid-work from the interrupted GLM 5.2 session (parametric UV half-landed in the working tree) and the §10z-six bone-table findings. Two production landings:
1. Shared PHYSIQUESKIN library + shape export path — the 653-file skinned-physique skip class closes. New pipeline_max_export_common/physique_skin.{h,cpp} (reusable for .clod / any other skinning consumer):
decodePhysiqueWeights — walks mod-app 0x2500 → 0x2512 → 0x2504 → N × (0x2506 → 0x0989), reads {u32 numBones, numBones × {u32 boneRef, Point3 offset, float weight}}, resolves boneRef via ~index into the Physique modifier's reference array (primary/rigid links) with a raw-index fallback for the ~4 % positive cross-link boneRefs.buildSkeletonBoneMap — scene-tree walk from the skeleton root (first resolvable bone's top-level ancestor under the scene root; CNodeImpl::parent() only — INode::parent() nlerrors on the root), orderedChildrenOf for Max scene order, duplicate names get _Second (matches buildSkeletonShape).applyPhysiqueSkinning — top-4 highest weights, normalize by sum, fill CMeshBuild::SkinWeights + BonesNames; vertices with no resolvable influence fall back to skeleton root weight 1.0 (the ConvertToRigid→Bip01 promotion class, coverage approximation).evalAndBuildMesh the weights are applied and PaletteSkinFlag is set; skinned meshes force the MRM branch so CMeshMRMSkinned::isCompatible can fire. Skin (Max 4+) still skips (zero corpus). Physique/Skin treated as geometry-neutral in mesh_eval (no unhandled-modifier warn — the reference disables them before mesh eval).INTERFACE_FILE appdata now WARN and continue (still without the weld), unblocking the many skinned armor pieces that carry both Physique and an interface file.2. Parametric Box + Plane UV generation (GLM mid-work finished). PRIMMESH::buildParametricMesh optional uvVerts out-param emits 3 × nFaces per-corner UVs (Box: geometric projection per face type onto in-plane axes, validated against ~/shape_export_dataset primuv_box; Plane: (0.5+x/w, 0.5+y/l)). Shape extractParametricPrimitive fills map channel 1. Cylinder/Sphere seam handling still open.
Corpus T3 after this session (shape_corpus.py --t3 -j 16):
| Metric | pre (§10z-six) | post |
|---|---|---|
| exported | 2841 | 3493 (+652) |
| not-produced | 749 | 97 (−652) |
| byte-identical | 12 | 12 |
| float-noise-eq | 972 | 975 |
| differ | 521 | 1122 (+601 skinned landings) |
| mapext-bucketed | 72 | 118 |
| lightmap-verified / -diff | 982 / 281 | 982 / 283 |
| skip classes | skinned-physique=653, remanence=82, mesh-eval=14, interface-mesh=10, water=1 | remanence=82, mesh-eval=14, water=1 (skinned-physique gone; interface-mesh no longer a skip) |
Coverage of the reference shape set: 79.2% → 97.3% produced. Spot-check fy_hof_armor01_*: all five pieces export as CMeshMRMSkinned, size within ~1–3% of reference, bone set nearly matching (used-bone order differs because first-use order depends on weight assignment; extra R Foot on ours where reference dropped it via bone-use). Residual DIFF on skinned is expected: (a) ConvertToRigid root promotion is only approximated, (b) MRM builder 2004-vs-now float divergence, (c) missing interface border-weld normals, (d) skeleton walk order vs Max GetChild order for the pre-remap BonesNames.
Open after this session (ranked by remaining corpus impact):
.veget / .clod — surface completion; .clod reuses PHYSIQUESKIN.Two hardening fixes this session, both surfaced by the 3-file T3 EXPORT FAIL class §10z-sept left standing. Both landed clean; a fresh full-corpus T3 sweep re-verifies the export failure count at 0 (was 3) with the coverage numbers otherwise unchanged (all 3 files rejoin the produced set as valid shapes, one of them as an explicit skip class the artist can act on).
LOD_MRM gating in the shape exporter — 2 of 3 EXPORT FAILs are latent tool bugs, closed. §10z-sept's Physique landing OR'd !buildMesh.SkinWeights.empty() into wantMrm, so any skinned node was force-routed onto the MRM branch even with LOD_MRM=0. That mislabelled ca_spaceship2.max and ship_tank_karavan.max (both large ship meshes; both LOD_MRM=0 in the source per artist intent) as CMeshMRMSkinned — a class where CMeshMRMSkinned::isCompatible (input verts < 5000) trivially passes for these hull-count meshes but MRM boundary growth then blows the post-MRM invariant _VBufferFinal.getNumVertices() < NL3D_MESH_SKIN_MANAGER_MAXVERTICES. The reference plugin (plugin_max/nel_mesh_lib/export_mesh.cpp:360) gates on LOD_MRM alone; both ship references are plain CMesh with SkinWeights per their class-name header. Fixed by dropping the OR: wantMrm = LOD_MRM != 0. Both files now export as CMesh, matching the reference exporter's routing verbatim.
CMeshMRMSkinned post-MRM VB invariant → recoverable status flag instead of an abort. The third EXPORT FAIL, ge_hom_acc_gauntlet_kami.max, is the genuinely bounded case: LOD_MRM=1, matches its reference's CMeshMRMSkinned class, but our post-MRM VB lands at 6514 verts vs the reference's 1461 (against a 642-vert input mesh — reference ~2.3× MRM growth is normal, ours ~10× is a Physique-decode noise class). The assertion at mesh_mrm_skinned.cpp:1353 is Linux __builtin_trap so it hard-aborts both pipeline_max_export_shape and, more consequentially, the engine at serial-read time (line 1149 — a broken shape file loaded into the game would kill it). The invariant IS load-bearing (CMeshMRMSkinned is optimized specifically to feed the shared CVertexStreamManager _MeshSkinManager, whose fixed-size preallocated VB is NL3D_MESH_SKIN_MANAGER_MAXVERTICES = 5000; over the limit the class cannot render at all in its optimized path), so weakening the check is not the fix.
Replaced with a proper error-reporting mechanism (not exceptions — the invariant violation is an expected, recoverable outcome for the export tool):
CMeshMRMSkinnedGeom gains bool _RuntimeCompiled (default false); compileRunTime sets it true on success, or logs an nlwarning with the counts and clears it on invariant failure.CMeshMRMSkinned::isRuntimeCompiled() exposes the flag.pipeline_max_export_shape and the reference plugin_max/nel_mesh_lib/export_mesh.cpp) check the flag after build(). On false: delete the CMeshMRMSkinned, surface an artist-facing message identifying the node and the fix (set LOD_MRM=0 to export as plain CMesh with SkinWeights, or split the geometry so each part fits), skip the node cleanly. Landed the same catch on both paths — the plugin_max copy uses outputErrorMessage (Max plugin's user-facing warning channel, same as its other export-time complaints); the headless tool uses stderr with a SKIPCLASS skinned-maxverts bucket entry._RuntimeCompiled accessor is the hook for a future render-side gate).The mesh_mrm_skinned.cpp change is engine code (nel/src/3d), not just tooling — same class of correctness improvement as §11's mixed-header per-chunk width tracking (surfaced by the exporter, but the underlying invariant enforcement lived one layer down).
Corpus T3 after this session (shape_corpus.py --t3 -j 16 measured, full 3590-source sweep):
| Metric | pre (§10z-sept baseline) | post |
|---|---|---|
| exported | 3493 | 3495 (+2, the 2 ship files now produce as CMesh with SkinWeights) |
| byte-identical | 12 | 12 |
| float-noise-eq | 975 | 978 (+3) |
| differ | 1122 | 1121 |
| mapext-bucketed | 118 | 118 |
| lightmap-verified / -diff | 982 / 283 | 982 / 283 |
| without reference | 1 | 1 |
| not-produced | 97 | 95 (−2, the 2 ships) |
| export failures | 3 | 0 |
| skip classes | remanence=82, mesh-eval=14, water=1 (the 3 export-fail cases were separate) | remanence=82, mesh-eval=14, skinned-maxverts=1, water=1 |
| material anim (.anim) byte-identical | 611 | 615 |
Coverage of the reference shape set: 97.4% produced (3495 / (3495+95) — the 95 not-produced classes are remanence 82 + mesh-eval 14 (SplineShape/Line/Rectangle) + skinned-maxverts 1 (gauntlet, artist-fixable per §1.7) + water 1 (single file, matches reference NULL-return)).
ge_hom_acc_gauntlet_kami.max is documented in defective_max_files §1.7 as a source-side authoring gap: the file's LOD_MRM=1 intent produces geometry that can't fit the skin-manager's fixed VB in our decode. The root cause is downstream of the assertion — our Physique decoder returns ~19% unresolved bone entries and 50 root-fallback verts (of 642 input), which manifests as weight-set discontinuities that MRM splits at. Closing the Physique cross-link record layout (PMD §10z-quat / §10z-six / MGF §M.3) is the same open item that gates skinned-shape byte-identity broadly; when it lands, the gauntlet's post-MRM count likely drops closer to the reference's 1461 and the invariant passes without an authoring change.
Handoff (unchanged from §10z-sept plus this session's additions):
.veget / .clod — as §10z-sept.Closed the §10z-cinq / §10z round 3 open item: an animated material's static user-texture-matrix value (48 bytes per animated material stage) was shipped identity even when the StdUVGen's offset/tiling/angle params carried non-default static values — the largest single-line class in the shape T3 DIFF bucket after §10z-sept.
Decode: readUVGen (pipeline_max_export_shape/material_build.cpp) pulls the offset/tiling/angle floats from the UVGen's reference-0 old ParamBlock via OLDPBLOCK::readOldParamBlock. Indices per §10z-cinq's ~/shape_export_dataset stduvgen_{baseline,offset,tile2x3,angle45} probes: 0 U Offset, 1 V Offset, 2 U Tiling, 3 V Tiling, 4 U Angle, 5 V Angle, 6 W Angle. Defaults are 0 for offset/angle, 1 for tiling — the SUVGen ctor already carries them; only override when the pblock carried a stored value (paramFloat's 0-for-missing would else zero out tiling).
Matrix formula: exact replication of Max SDK StdUVGen::GetUVTransform (tm = identity; tm.Scale(tiling); tm.RotateX(uAngle); tm.RotateY(vAngle); tm.RotateZ(wAngle); tm.Translate(offset);, all post-multiplied), then the CExportNel::uvMatrix2NelUVMatrix V-axis-flip similarity transform (dest = C * dest * C where C.rot = (I, -J, K), C.pos = J). Small row-vector helpers (m3Scale/m3RotateX/Y/Z/m3Translate) inline in material_build.cpp.
Animated params: the 0x0200 marker path (§10k) means the runtime anim track drives the matrix each frame. The baked value at export reflects the pblock's own value chunk (usually 0 for animated offset). Not routed through floatValueAt0 — the animation track's ordinary interpolation handles the runtime evolution.
Corpus impact (shape_corpus.py --t3 -j 16 measured, full 3590-source sweep):
| Metric | pre (§10z-huit baseline) | post |
|---|---|---|
| exported | 3495 | 3495 |
| byte-identical | 12 | 12 |
| float-noise-eq | 978 | 1064 (+86) |
| differ | 1121 | 1035 (−86) |
| mapext-bucketed | 118 | 118 |
| lightmap-verified / -diff | 982 / 283 | 983 / 282 (±1) |
| without reference | 1 | 1 |
| not-produced | 95 | 95 |
| skip classes | remanence=82, mesh-eval=14, skinned-maxverts=1, water=1 | unchanged |
| material anim (.anim) byte-identical | 615 | 648 (+33) |
Handoff (post-§10z-neuf):
.veget / .clod — surface completion.Picked up the §10z-neuf handoff's largest remaining not-produced class: CSegRemanence (82 files), which also needed the Max Shape-superclass BezierShape/Spline3D decode the residual mesh-eval / cmb XRef spline gaps share.
1. Shared SPLINESHAPE library (pipeline_max_export_common/spline_shape.{h,cpp}) — reusable for remanence, future mesh-eval spline tessellation, and cmb XRef Shape fallback:
0x2900 (numKnots) + 0x2904 (closed) + 0x290a (compact knot array) + 0x290d.ktype i32 + ltype i32 + du f32 + Point3 p0 + Point3 p1 + Point3 p2 + flags u32. p0 is the knot point matching ShapeObject::InterpPiece3D endpoints (corpus-verified against every remanence reference corner after objectToLocal). Full format write-up: max_geometry_formats Part N.(0x0a,0), Line (0x1040,0), Rectangle (0x1065,0); SuperClassId SHAPE=0x40. No new typed scene class — peek-only, T1/T2 unaffected.2. REMANENCEBUILD::buildRemanenceShape — replicates CExportNel::buildRemanence (plugin_max/nel_mesh_lib/export_remanence.cpp):
REMANENCE_SLICE_NUMBER (def 2→clamp ≥2), SAMPLING_PERIOD (def 0.02), ROLLUP_RATIO (def 1), SHIFTING_TEXTURE, optional animated-material name.buildMaterials (≠1 → SKIP, same as reference).objectToLocal = objectTM · Inverse(nodeTM) (offset PRS × nodeTM, same as water/mesh).decompMatrix(getLocalMatrix).compareShapesFields gains a CSegRemanenceShape field walk (transform double-cover-aware, corners float-noise, slices/shift/material shader structural) so the class lands in FLOATEQ rather than the unclassified DIFF fall-through.Corpus T3 (shape_corpus.py --t3 -j 16, full 3590-source sweep):
| Metric | pre (§10z-neuf) | post |
|---|---|---|
| exported | 3495 | 3577 (+82) |
| not-produced | 95 | 13 (−82) |
| byte-identical | 12 | 14 (+2 remanence) |
| float-noise-eq | 1064 | 1144 (+80 remanence) |
| differ | 1035 | 1035 |
| mapext-bucketed | 118 | 118 |
| lightmap-verified / -diff | 983 / 282 | 982 / 283 |
| skip classes | remanence=82, mesh-eval=14, skinned-maxverts=1, water=1 | mesh-eval=14, skinned-maxverts=1, water=1 (remanence gone) |
| material anim (.anim) byte-identical | 648 | 654 |
Trail-only subset: 78/78 remanence → 2 byte-identical + 76 FLOATEQ, 0 differ, 0 export failures. Corners and appdata params bit-exact vs reference on sampled trails (tr_wea_dague, ge_wea_epee2m, fy_wea_hache2m, ma_wea_lance1m); residual byte delta at file offset ~0x161 is material serial / signed-zero quat class (double-cover (0,0,0,-1) vs (-0,-0,-0,-1)), same FLOATEQ tier as water.
Coverage of the reference shape set: 97.4% → 99.6% produced (3577 / (3577+13)).
Handoff (post-§10z-dix):
.shape for plain splines. May shrink the not-produced count if any of these nodes actually have refs via a different path; otherwise they're authoring-side / not-a-shape.UVWMAP library scaffolded (pipeline_max_export_common/uvw_map_mod: pblock UVWMAP_* indices, gizmo PRS, 0x2510 ctx, MapPoint for planar/cyl/sph/ball/box/face). Production apply is gated until the gizmo TM × Length/Width/Height composition is GT-validated (applying a wrong projection overwrote baked UVs and regressed 2 FLOATEQ files). Fix gen_shape_export_dataset.ms UVW cases (Max 9 SP2 uv.gizmo API) first, then un-gate. Unwrap UVW (38), FFD(box) (20) still open..veget / .clod — surface completion; .clod reuses PHYSIQUESKIN..veget tool (2026-07-09, primitive-UV + write-bug + veget session)Three landings this session — the parametric-UV close, a real correctness defect the corpus tester couldn't catch because the defective shapes couldn't even be loaded, and a whole missing exporter that closes one of the two MISSING coverage-table rows.
pipeline_max_export_veget — .veget export (126 corpus files, was MISSING). New sub-tool that replicates NelExportVegetable (plugin_max/nel_mesh_lib/export_vegetable.cpp) headless: iterates scene-root $geometry nodes with NEL3D_APPDATA_VEGETABLE=="1" and DONOTEXPORT!="1", evaluates the mesh through the shape exporter's existing scene_lib + mesh_eval + material_build + mesh_build modules (the CMakeLists picks up those .cpp files directly rather than duplicating them — one compilation unit per module, linked into both the shape and veget executables), verifies the mesh has UV1 + exactly 1 matrix block + 1 render pass (the reference's own constraint check — vegetables render through the single-VB/single-material grouped path), copies the CMesh's VBuffer + PB into CVegetableShapeBuild along with the vegetable appdata flags (VEGETABLE_ALPHA_BLEND / _ON_LIGHTED / _OFF_LIGHTED / _OFF_DOUBLE_SIDED / _FORCE_BEST_SIDED_LIGHTING / BEND_FACTOR / BEND_CENTER), calls CVegetableShape::build, and serializes. Corpus (veget_corpus.py --all -j 12): T1/T2 126/126 corpus roundtrip clean. T3 505 exported: 92 byte-identical + 397 size-eq + 16 size-diff CORRECTED 2026-07-10 (§10z-douze): those T3 counts were an artifact of a -j race in the driver's shared-outdir before/after snapshots (nondeterministic, ~4-6× inflated); the true serial-equal state is 126 exported: 25 byte-identical + 97 size-eq (float-noise byte-diff) + 4 size-diff, 0 export failures, 0 references not produced. All 126 reference .veget files across the ecosystems are produced. The size-diff outlier class is Ju_DebrisFeuillesA-family (544 vs 424 = structural gap, left as follow-up). Coverage-table .veget row moves from MISSING to MOSTLY.
CMeshMRM shapes were being written malformed via CMemStream (fixed same session). Root cause is a NL3D CMemStream::seek limitation this repo already documents (§9 T2 note: "going through CMemStream doesn't work because its length() returns the current write position and CStorageChunks::leaveChunk needs to seek past that position to restore after patching the size field"). CMeshMRMGeom::save (nel/src/3d/mesh_mrm.cpp:1942-1972) uses the same seek-back-then-forward pattern to patch its per-lod offsets: capture absCurPos = f.getPos(), seek back to the placeholder, write the real offset, seek forward to absCurPos — and the forward seek fails on CMemStream because seek(begin) checks offset > length() where length() is the current write position (Pos), not the max ever written. When the forward seek silently fails, the writer overwrites the still-unwritten lod-offset placeholders and the resulting file is truncated at each lod boundary. The corpus tester's --compare mode couldn't load these files — Read error ... (End of file?) — so they were counted as DIFF without any comparison ever running against the reference; every unskinned CMeshMRM in our corpus output landed there. CMeshMRMSkinnedGeom::serial (mesh_mrm_skinned.cpp:1092) doesn't use the seek-back pattern (it just does serialCont(_Lods)), which is why skinned MRMs and plain CMesh shapes worked. Fix: same technique pipeline_max_corpus_test uses for its own T2 round-trips — route the initial CShapeStream::serial through a PID-suffixed COFile temp (/tmp/pipeline_max_export_shape.<pid>.tmp), then read it back into a byte vector for the version-byte patch and final write. Verified: fresh tr_bar_acc_pouf.shape, ca_hof_casque01.shape etc. now load cleanly. Corpus impact stays at 14/1145/1034/118/980+285 CORRECTED 2026-07-09 (anim-audit session): that recorded sweep ran against a stale binary (the §10r trap, caught when a fresh full rebuild reproduced different numbers twice). The write fix DOES move the corpus counts substantially — freshly-rebuilt HEAD measures 94 byte-identical (+80: valid unskinned CMeshMRM files now byte-match their references) + 1056 float-noise-eq + 1043 differ + 118 mapext + 916 lightmap-verified + 349 lightmap-diff + 1 no-ref; 13 reference shapes not produced; 0 export failures — the lightmap-verified/diff split also re-shuffles because previously-unloadable files now actually run the mask compare. The correctness point stands either way: pre-fix output was unloadable garbage in-game, post-fix it's a valid shape the loader accepts.
Follow-up on §10z-cinq's still-open item (Cylinder/Sphere seam handling that §10z-sept skipped when it wired Box+Plane) and §10z-dix's handoff item 4. Both primitive UV generators now land in the shared library and match ~/shape_export_dataset GT.
Shared u-shift rule. Both generators emit per-corner UVs (no dedup here; the mesh_build path dedups the final VB on (pos, normal, UV) — same shape Box/Plane already produce). To reproduce Max's seam-crossing behavior (the "extra" UV vert with u=-0.125 or u=1.0 the manifest shows when a face wraps across the k=0/segs boundary), each tri applies a within-tri shift: given three corner u values (uA, uB, uC), keep consecutive |Δu| ≤ 0.5 by adding/subtracting 1 from uB relative to uA, and uC relative to uB. This reproduces the exact seam-duplicate values without ring-mod arithmetic (the seam vertex appears as u=1.0 in a wrap-around fan tri, or u=-0.125 in the reverse — both are the same rotation of "unwrap the wrap").
Cylinder. Corpus-validated against primuv_cyl (radius=0.5, height=2, hs=2, sides=8; 26 verts, 48 faces, 34 mapverts):
u = fract(0.75 + k/N) — the 0.75 offset places the seam at k=N/4 (the +Y direction), Max's canonical cylinder UV origin (per primuv_cyl u_ring at k=0..7 = 0.75, 0.875, 0.0, 0.125, 0.25, 0.375, 0.5, 0.625).v = 0 for the bottom cap (all cap corners collapse to v=0), z/h for the sides, 1 for the top cap.Sphere. Corpus-validated against primuv_sphere (segs=16, rows=8; 114 verts, 224 faces, 153 mapverts):
u = k/segs — no offset, seam at the +Y direction (k=0 vertex, whose angle is π/2 in the code — see SPH_TRI iteration).v = 1 - i/rows for ring i by INDEX, not by latitude angle — Max's Generate Mapping Coords maps UV rows uniformly regardless of the actual z coordinate on the sphere (which is cos(π·i/rows), non-linear). Corpus-validated: primuv_sphere rings sit at z=0.924, 0.707, 0.383, 0, -0.383, -0.707, -0.924 but their UV v values are 0.875, 0.75, 0.625, 0.5, 0.375, 0.25, 0.125 (linear in i).Corpus impact (shape_corpus.py --all --t3 -j 12, full 3590-source sweep, pre-baseline captured same session at §10z-dix numbers):
| Metric | pre | post |
|---|---|---|
| exported | 3577 | 3577 |
| byte-identical | 14 | 14 |
| float-noise-eq | 1144 | 1144 (small ±) |
| differ | 1035 | 1035 (small ±) |
| mapext-bucketed | 118 | 118 |
| lightmap-verified / -diff | 983 / 282 | 983 / 282 |
| skip classes | mesh-eval=14, skinned-maxverts=1, water=1 | unchanged |
Small movement — the primitive-shape class is small (~15 Cylinder + 18 Sphere direct-mesh corpus nodes per the §10y count), and most of them are collision-hulls / untextured stand-ins where UV differences don't move the compare verdict. The two GT files (primuv_cyl, primuv_sphere) validate the formulas; the corpus impact was the primary open question, and matches expectations.
Handoff (post-§10z-onze), unchanged from §10z-dix except item 4 closed:
gen_shape_export_dataset.ms). Unwrap UVW (38), FFD(box) (20) still open..veget / .clod — surface completion.§10d deliberately deferred light/material tracks as "zero corpus signal" for the anim-process corpus. The shape process is a different surface: shape_export.ms runs NelExportAnimation #(node) for every scene object with NEL3D_APPDATA_AUTOMATIC_ANIMATION, including lights. Enumerating ~/pipeline_export/**/shape_anim/*.anim (110 files) found the previously unshipped classes:
| Class | Count | Example |
|---|---|---|
| transform-only (pos/rot/scale) | 48 | conerotor.anim, rotor.anim |
| texture-matrix (texmat) | 47 | waterfall *.VTrans0 — already §10k |
| LightmapController (animated light groups) | 11 | brazero-ext1, lanterne-int1 |
| empty NEL_ANIM (29 B) | 3 | Sun.anim |
| morph | 1 | ge_bt_kami_destroyer |
Light-group mechanism (reference export_anim.cpp addLightTracks + max_lightmap_support.txt):
0x30).NEL3D_APPDATA_LM_ANIMATED ≠ 0 and NEL3D_APPDATA_LM_ANIMATED_LIGHT = animation name (empty after clearing Sun/GlobalLight/(Use NelLight Modifier)).getControlerByName(node, "Color") → NeL track LightmapController.<name> as CTrackKeyFramerBezierRGBA (Bezier Point3 keys, float 0..1, eval-time copyToValue ×255).stuff/animated_light/fyros_city_animated_lights.max (11 Omni lights).Parser expansion — typed Point3/Color controllers:
| Class | ClassId | Superclass | Default chunk | Key chunk | Notes |
|---|---|---|---|---|---|
| Bezier Point3 | 0x200A |
CTRL_POINT3 0x9005 |
0x2501 (12 B RGB) |
0x2526 (same as PosBezier, 80 B/key) |
Leading empty-or-structured marker 0x8499 must be a known claim-pass-through or the key table is never reached |
| Bezier Color | 0x2011 |
CTRL_COLOR 0x9009 |
same | same | registered; corpus lights use Point3 |
| TCB Point3 | 0x442314 |
0x9005 |
0x2501 |
0x2521 |
registered for completeness |
CControlKeyFramerBase::isKnownChunkId now accepts 0x8499. Superclass descs for 0x9005 (already present) and 0x9009 (new) registered. T2 green on the animated-light file (Scene parse→build byte-identical).
Export path:
TRACKBUILD::buildATrack in pipeline_max_export_common (typePos/Rotation/Scale/Float/Color) — NeL CTrackKeyFramer* key conversions matching export_anim.cpp. Color Bezier uses CKeyBezierVector floats; Linear color uses (uint8)val like the reference.SHAPEANIM::buildNodeAnim expands from texmat-only to full single-node addAnimation: node tracks → material texmat → light tracks → morph. Color controller search is restricted to the light object subtree (not the node PRS — PosBezier false-matched otherwise).pipeline_max_export_shape runs a separate anim pass over all nodes (not only geometry that produced a shape), matching for node in objects do isAnimToBeExported.typeColor → setLoopMode(true); all other types stay false. All 11 light references need loop=true (single residual byte when forced false). Full ORT decode from controller storage (candidates: 0x3002 values 24/25 on Point3 vs 0 on PosBezier; 0x8499 54-byte payload) remains open — same class as §10b's "ORT bit not located" for the anim-process corpus.Validation:
pipeline_export/common/objects/shape_anim + core4_data/objects.conerotor.anim + rotor.anim from fy_cn_taverne_clusterized.max byte-identical.--only animated_light: material anim (.anim): 11 byte-identical, 0 differ.Still open on anim (not this session's shipping bar): biped IK exactness (§10s); full ORT bit decode (replace the typeColor loop provisional); material color tracks (ambient/diffuse/…) remain zero corpus signal; camera FOV still unkeyed.
Picked up §10s item (1) (the arm-pin — "the largest single remaining error mass", the strike/coup files that head every worst-offender list). The decode prerequisites §10s(1) named "probe-able without Max" turned out to be fully corpus-data-readable, the pivot-IK machinery (buildPivotSessions/applyPivotIk) was already limb-generic (the §10r leg model), and a four-gate isolation of the strike class lands it corpus-net-positive — the first net-positive arm variant (the prior wrist-pin attempts, §10q/§10r item 8, were both net-negative). It ships env-gated (PMB_BIPED_IK_ARMS, off by default) pending one residual regression class; the default export is byte-identical to the §10r baseline.
The arm end-effector space + palm pivot — decoded from corpus data (no Max). The arm keytrack record (0x0134 R / 0x0136 L, 110 floats/key, 4-float header) carries the SAME tail layout the §10r leg decode established: [11] space flag, [12] IK blend, [18..20] wrist world pos (Object/world space), [28..31] hand quat, [98] ikPivotIndex, [101..103] pA (the active palm-pivot world pos), [105..107] pB. Dumped via a pure-python extractor (maxole.walk_chunks over the Scene stream) on fy_hof_co_l2m_coup_fort_03: the striking R arm's planted keys (k1–k5) are blend=1, space=2, with |pA − wrist| ≈ 0.103 constant across the plant — a palm pivot ~10 cm from the wrist (the §10r "~7 cm palm pivot" for arms), while the non-striking L arm stays blend=0 (FK). So for the strike class the wrist world target is [18..20] and the pivot is [101..103], exactly the leg's ankle/pA shape — the machinery applies verbatim. The blocker §10s(1) flagged ("the stored arm end-effector SPACE varies per file") is real only for the gesture/Body-space minority; the strike class is uniformly space=2 (Object/world), which the gate requires.
Four gates isolate the strike class (the prior net-negative attempts applied the solve to ALL planted arms — the gesture wrist-pin and locomotion swing classes regressed). Each was added in buildPivotSessions/applyPivotIk and measured on the subset (below) before the full sweep:
buildPivotSessions): arms accept a session only when |pLocal| > 2 cm (a real palm pivot, not the wrist-pin gesture case pA == wrist) AND the first key's space flag rec[11] == 2 (Object/world — the only space in which rec[18] is an authoritative world wrist). Replaces the old limb==0 ⇒ pLocal = 0 forcing that crippled arms to wrist-pin.buildPivotSessions): arms reject a session whose COM yaw deviates > 0.7 rad across its keys — turning/spinning body motion (toupie, demitour, tourne). The arm hand-rotation has the §10r item-7 yaw-frame but NOT the item-9 large-turn angle+residual decomposition the legs carry, so a planted hand during body rotation swings wrong.buildPivotSessions): arms solve only genuine plants — a large pivot motion (D > 5 cm) is an arm SWING (locomotion, a run), not a deliberate pin (unlike the legs' heel-strike, an arm's stored wrist path during a swing is already the FK arc, so solving IK to it regresses the course/marche class).applyPivotIk): arms solve only when the wrist is pulled > 2 cm from FK (|T − ankFk| > 0.02) — a smaller correction is a held pose (engarde/idle_attente) whose FK wrist is already correct; solving it only injects the in-plant floor as noise. (ROT-variant A/B — PMB_BIPED_IK_ROT off/yaw/full — confirmed the static-pose regressions are the position solve, not the rotation override, which is a no-op there.)The hand-rotation yaw-frame channel (§10r item 7) is generalized to arms (m_HandWorldRot[2], parallel to m_FootWorldRot[2]; the build/apply blocks now run for limb==0 under PMB_BIPED_IK_ARMS) so a planted hand does not ride the moving COM — without it the emote class regressed (fy_hom_emote_beta_testeur +0.12 → −0.16 with it).
Tooling — anim_ik_subset.py (pipeline_max_corpus_test/): a fast (~30 s) subset A/B for IK-heuristic changes, because a full anim_corpus.py T3 sweep over the 3173 biped direct-ref files takes 7–10 min and per-file probes are unreliable (§10r). Sweeps the known worst offenders + flip-floppers (both the win and the regress classes are in MUST_INCLUDE) + a seeded random sample, exports each twice (baseline and with an env override), and reports the net improved/regressed tally + top movers — the signal for whether a gate change is corpus-net-positive BEFORE a full sweep. Self-checks clean (--env BASE ⇒ all same). Reusable for any PMB_BIPED_IK_* A/B.
Full-corpus result (PMB_ANIM_DELTALOG A/B vs the §10r baseline, 3167 biped direct-ref files): 77 improved / 31 regressed / 3059 same; mean worst 0.1500 → 0.1475 (−0.0024); files over the 0.25 tol 594 → 562 (−32); median 0.06886 → 0.06767. T1/T2 4452/4452 unchanged (eval-path only, no chunk parsing touched). Wins are large and on exactly the §10s worst-offender class — the dynamic arm plants: the mount-aquatique attack family (0.59→0.03, 0.52→0.06, …), emote_indifferent (0.57→0.03), swim_hisser (0.49→0.12), coup1 (0.68→0.25).
Residual regression class — held poses, storage-identical to the wins. The 31 regressions are dominated by the idle_attente / engarde held-pose family (fy_hof_co_a2m_idle_attente2 +0.57, engarde_attente1/2 +0.49/+0.52) — a 2-handed guard or waiting pose where the L-arm 2-bone solve diverges (L Forearm +0.85 on engarde_attente2). These are storage-identical to the mount-pin wins (same blend=1, space=2, palm pivot, near-static target D < 0.005), so motion alone cannot gate them out — the reference world-pins the mount hand but FK-holds the guard hand, and that intent is not stored. The proximate mechanism is the 2-bone elbow-direction ambiguity on a bent-back L arm (a wrong hinge sign rotates the forearm ~π); a robust signed-angle elbow heuristic (the §10r item-6 phiNow fix, applied to the arm hinge) is the candidate fix, but it needs subset iteration against the held-pose class (now in MUST_INCLUDE). Until that closes, the arm-pin stays env-gated: the 31 regressions (some +0.5) are too large to default, even though the net is positive and the gate metric improves.
Status / next: PMB_BIPED_IK_ARMS=1 (off by default ⇒ byte-identical baseline). Net-positive and the strike worst-offender class is solved; the held-pose/engarde regressions are the remaining gate (elbow-direction + the world-pin-vs-FK-hold intent ambiguity, the latter possibly undecidable without a Max-side differential probe). The anim_ik_subset.py tool + the MUST_INCLUDE win/regress rosters make the next iteration a ~30 s-per-change loop.
Picked up the §10s-bis residual: engarde/idle held-pose regressions of +0.5 on the L forearm under PMB_BIPED_IK_ARMS=1. Probed frame-by-frame against direct references and the arm keytrack tail.
Diagnosis (not the dual-fold / pure hand-rot hypotheses). Storage for engarde L arm is two identical plant keys (blend=1, space=2, |pA−wrist|≈0.10) spanning the whole clip — the same shape as the mount-attack wins. Mid-interval:
Dual ±phiT selection by FK-mid proximity was tried and net-negative (both folds often reach T; the fold closer to FK mid is not always the reference fold, and scoring without a hard reach-first rule preferred near-FK mids that missed T on mount). Pure hand-rot disable also killed emote wins that need the yaw-frame.
Shipped gate — midFlip reject (arms only). After computing the 2-bone solution, if qdistApprox(ikMid, fkMid) > 0.18, drop both the position solve and the yaw-frame hand override for that frame (restore footRotOld). Threshold pinned by histograms: mount max midFlip ≈0.15 (all accept); engarde mass at 0.25–0.48 (reject). Legs unchanged.
Full-corpus A/B (3173 biped direct-ref, arms-on with midFlip gate vs legs-only baseline): 69 improved / 25 regressed / 3079 same; mean worst 0.1500 → 0.1474 (−0.0025). Versus §10s-bis (77/31, −0.0024): fewer large engarde regressions (engarde_attente2 +0.52 → +0.05; several attente files to 0), mount/emote/swim wins retained. Still env-gated (PMB_BIPED_IK_ARMS=1).
Residual (not closed by midFlip). fy_hof_co_l2m_idle_attente* (+0.48/+0.27): midFlip stays <0.18 (max ≈0.10) while the local forearm track (exported under the reoriented upper arm) jumps 0.09→0.62 — world mid near FK does not imply local forearm stability when upper-arm world rot moves a lot. Tighter midFlip kills mount wins. Needs a real arm hinge / local-forearm model (or Max-side GT), not another motion gate. Other small residuals: ca_hom_trooper_impact, a few stun/regarde idles.
Method notes. anim_ik_subset.py remains the ~30 s iteration loop; full A/B is the only accept criterion. VS2008/x87 (§10m-bis) already proved this class is model not codegen — no x87 re-run needed for the gate.
Picked up the §10s-ter residual (l2m idle_attente local-forearm) and the observation that the arm-pin did not generalize: coup_fort stayed FK-identical under ARMS (COM-yaw whole-session reject + large-D skip killed the striking R arm), engarde/l2m regressed under the position solve, and mount-attack worst tracks did not improve.
Diagnosis (storage + per-file export A/B). Palm-pivot Object-space plants split into three behavioral classes with the same storage shape (blend=1, space=2, |pA−wrist|≈0.07–0.10):
| Class | Examples | Storage | Reference intent | 2-bone solve |
|---|---|---|---|---|
| Dynamic small-D plant | emote_indifferent, swim_hisser, fus_idle_attente1 |
multi-key, D ≲ 5 cm | world-pin, FK drifts | helps |
| Static 2-knot held plant | l2m_idle_attente*, engarde_attente*, mount grips |
2 identical keys, D≈0 | often FK-hold (authored pose) | hurts (hinge ambiguity / local forearm) |
| Large-D weapon path | coup_fort strike intervals |
multi-key, D ≫ 5 cm | spline through hand keys | 2-bone wrong fold; leave FK |
midFlip alone cannot separate class 1 from class 2 (l2m midFlip p50≈0.11 < 0.18 while still regressing). locFlip thresholds that kill l2m also kill swim. The discriminator is session shape, not fold magnitude.
Shipped gates (on top of §10s-bis palm/space/correction + §10s-ter midFlip):
Also added experimental PMB_BIPED_IK_ROT=hold (piecewise world-hold with key snaps — PODA eq-10 candidate for in-plant foot rotation). Corpus probes on course/demitour/marche are net-negative vs the yaw-frame default; kept as A/B only, not default.
Full-corpus A/B (3167 biped direct-ref, ARMS vs legs-only baseline): 15 improved / 3 regressed / 3149 same; mean worst 0.1500 → 0.1495 (−0.0005); median 0.06886 → 0.06864; over-0.25 tol 594 → 591. Max regression +0.0205 (idle attitude, noise-tier). Wins include emote_indifferent 0.57→0.03, swim_hisser 0.37→0.03, tr_hof_sit_init 0.39→0.20. T1/T2 untouched; structural fails 0; non-biped 10/10.
Default ON. PMB_BIPED_IK_ARMS now defaults to enabled (unset or any value other than 0); PMB_BIPED_IK_ARMS=0 restores the legs-only baseline for A/B. Prior env-gated landings (§10s-bis/ter) needed the gate because regressions reached +0.5; the static-plant drop removes that class.
Still open (not closed by this session):
coup_fort R arm worst 0.93)~ — partially closed §10s-cinq (multi-key large-path dual-fold; residual ~0.39–0.58 on the best improved files, many coup variants still FK-gated).Method. anim_ik_subset.py for the ~30 s loop; full PMB_ANIM_DELTALOG A/B over 3167 files for accept. Pure-python arm-record dump via maxole.walk_chunks on the Scene stream (no Max) classified the three plant classes before any gate change.
Picked up the §10s-quat residual: large-D weapon plants (coup_fort R arm worst ~0.93) stayed FK-identical under ARMS because the §10s-bis/quat arm D-window skipped every interval with W-channel D > 5 cm. That also blocked the deliberate multi-key strike path whose mid-interval frames head every worst-offender list (R Forearm/UpperArm/Hand at t≈0.53 on fy_hof_co_l2m_coup_fort_03).
Storage discriminator (no Max). Pure-python arm-record dump (maxole.walk_chunks over Scene) on coup_fort vs course:
| Class | stored pA travel | W-channel D | Example |
|---|---|---|---|
| Weapon multi-key path | 0.3–2.2 m across plant keys | large | coup_fort_03 ArmR iv[1,2] DpA=2.22 |
| Hand-rot / locomotion hold | ≈0 (pA fixed) | can exceed 5 cm | course ArmL, W-only large D |
| Borderline slide | 5–10 cm | small/medium | l2m_to_walk 5.8 cm |
| Static 2-knot hold | ≈0, 2 keys | ≈0 | engarde/l2m idle (already dropped §10s-quat) |
W-channel D alone cannot separate weapon paths from course (both can be large); stored pA travel is the authoritative path signal.
Shipped model (buildPivotSessions / applyPivotIk):
storedD > 10 cm and the session has ≥4 W knots. Otherwise FK (restores course/strafe, l2m_to_walk, first_pilon 2-knot, coup2_milieu 3-knot L-arm).SPivotInterval::LargePath: set when (1) holds for a weapon path.coup2_milieu; position solve is the accuracy lever. PMB_BIPED_IK_ROT=full still forces Full for A/B.Rejected on the way: enabling all W-channel large-D (course +0.16); dual-fold with locFlip gate on large-path (locFlip 0.96 rejects good midFlip 0.11 frames — FK elbow angle is already wrong mid-swing); Full world hand squad; 2-knot / 3-knot large-path unconstrained dual-fold (first_pilon +0.38, coup2_milieu L +0.20); exempting LargePath from the COM-yaw session drop (opens emote_beta_testeur large-path plants → +0.12; a1m_coup1's file-worst is the foot track so no file-level win).
Full-corpus A/B (3167 biped direct-ref, new default vs PMB_BIPED_IK_ARMS=0 legs-only): 23 improved / 3 regressed / 3141 same; mean worst 0.1500 → 0.1488 (−0.0012); median 0.06864 → 0.06842; over-0.25 tol 594 → 588 (−6). Max regression +0.053 (emote_praying, noise-tier). Wins: coup_fort_03 0.93→0.51, coup_fort 0.66→0.39, hom_coup_fort_03 0.88→0.58, l2m_coup1 0.42→0.14, plus retained small-D wins (emote_indifferent 0.57→0.03, swim_hisser 0.37→0.03). T1/T2 untouched (eval-path only); structural fails 0; non-biped 10/10. Multi-seed subset A/B (n=259, seeds 42/7/99): 0 arm regressions.
Still open after §10s-cinq: residual ~0.4–0.6 on improved strike files — partially closed §10s-sept (moderate-miss FK hold: coup_fort_03 0.51→0.23); some a1m/a2m coup variants still FK-gated by plant shape, others improve arms but file-worst stays a foot track (e.g. a1m_coup1); in-plant foot rotation; blend ramps; mount static-plant residual. VS2008/x87 (§10m-bis) already proved the lever is the model, not codegen — no x87 re-run for this landing. §10s-six decodes the residual pole field and the offline GetIKActive oracle without changing the §10s-cinq bar.
Method. Per-file worst-track dumps (parse_anim + _key_delta) to pin R-arm mid-interval; pure-python stored-pA probe; anim_ik_subset.py ~30 s loop; full PMB_ANIM_DELTALOG A/B for accept.
Picked up the residual strike class (~0.4–0.6 on coup_fort_03 R Forearm after §10s-cinq) and the offline Max 2010 biped.dlc probe the user enabled for black-box analysis (local ~/max2010_biped_probe/, out of repo). Two independent decodes landed; the apply-side pole twist is env-gated off after measured regressions.
[22..24] is the mid-bone pole vector§10c listed [22..24] as an "unidentified unit vector (≈knee-plane-normal-ish)". Against pipeline_max_export_anim --dump-samples positions at plant keys:
| Candidate | coup_fort_03 ArmR plant keys |
Notes |
|---|---|---|
| Pole (mid − proj_reach(mid)) | 0.75–0.97 ·uv | best match on 4/5 plant keys |
| Upper-arm local Y | 0.88–0.97 | coincides with pole when hinge is about local Z |
| Plane normal (upper×forearm) | 0.09–0.82 | not the field |
| Reach axis / bone dirs | low |
Same field on legs (fy_hof_a_course) scores 0.72–0.99 ·pole on planted keys (lower when nearly straight — pole ill-defined). Storage is Y-up unit; NeL convert (x,y,z)→(x,-z,y).
Channel build (buildChannels): m_ChPole[limb][side] — planted keys only (0.5 < blend ≤ 1.5), sign-chained against the previous knot so TCB does not flip through free-key poles. Legs take +legShift like every field above index 2. Require ≥2 planted knots or the channel stays empty.
Apply (applyPivotIk): twist the solved 2-bone chain about the reach axis T−H so the mid-bone pole matches the TCB pole. Gated by PMB_BIPED_IK_POLE=1 (default off). Measured:
| Variant | coup_fort_03 file-worst |
Notes |
|---|---|---|
| §10s-cinq default (no pole) | 0.5116 (R Forearm @ t=1.1) | shipping bar |
| Always-on arm+leg pole | 0.857 | L Foot/Thigh +0.8 — legs must not twist yet |
| Planted-only arm always | 0.642 | small-D swim regressed vs arms-off |
| LargePath-only arm pole | 0.642 | residual peak is on LargePath; twist still hurts |
| Full world hand for T only | 0.525 | neutral-to-worse vs 0.51 |
Residual peak on coup_fort_03 is the last LargePath interval [4640,5600] (storedD=0.91, hinge 3.126→2.431): dual-fold places the wrist but the elbow plane / hand path still diverge from the reference by ~0.5 rad. Pole alone cannot fix a wrong reach target. Channel stays built so future A/B is one env flip.
Host (CreateNewBiped salvage → IBipMaster12* = BipMaster+72) stamps SetPlantedKey/SetFreeKey and samples slot 123. Disasm + key dumps:
float @ +0x20 is the IK weight (f20=1 plant, 0 free) — same role as file-format blend [12].GetIKActive(t) = look-ahead / bracketing: if next exists, prev.f20>0 OR next.f20>0; exact key → that key's f20.Keep the both-keys plant gate (planted[k] && planted[k+1] on blend≈1). Do not replace it with GetIKActive. Probe details live outside the repo; implications only are recorded here.
Also reconfirmed: freestanding IOurBipExport::GetBipedPos/Rot remain zero stubs; live pose GT needs slots 105/106 on a real BipMaster*. Math gold path (~/max2010_math_probe) stays green for decomp_affine reassembly (~6e-7).
coup_fort_03 (0.511583) until the §10s-sept moderate-miss hold.m_ChPole, planted-only build, PMB_BIPED_IK_POLE=1 experimental twist.biped_anim.h identify [22..24] as pole; LargePath still FK hand + dual-fold when miss is large.Still open after §10s-six: residual LargePath mid-interval (addressed §10s-sept); foot rotation; blend ramps; 3-knot large swings; ikAnkleTension storage (located [109] — see residual-probe landing below).
Picked up the §10s-cinq/six residual: fy_hof_co_l2m_coup_fort_03 file-worst 0.5116 on R Forearm @ t=1.10 s (frame 33) — pure mid-interval error on the last LargePath plant [4640,5600] (exact at plant keys). Offline residual suite (~/max2010_biped_probe/OFFLINE_RESIDUAL.md) pinned the failure mode without Max GT:
| Observation | Evidence |
|---|---|
| Dual-fold places wrist on T | IKDBG missT≈0 once solve engages; FK miss ≤ 6.4 cm mid last plant |
| Residual is elbow over-bend, not bare reach | Ref FA local quat stays ≈identity (hinge≈π) until late; dual-fold aIk from law-of-cosines bends early → FA qdist 0.51 |
| FK mid-interval is closer to ref than dual-fold on that plant | FA FK residual peaks ~0.23 vs IK 0.51 on frames 29–35 |
| First strike LargePath still needs dual-fold | Interval [2240,2720] FK miss up to 0.53 m, midFlip 0.9 — arms-off worst 0.93 |
Shipped model (applyPivotIk): on arm LargePath intervals, clear solveNeeded when |T − wrist_FK| < 6.5 cm (just above residual-plant peak miss 6.44 cm). Those frames keep the stored upper/hinge FK path. Large-miss LargePath (weapon swing) still dual-folds. PMB_BIPED_IK_LP_HOLD=0 restores always-dual-fold for A/B.
Rejected on the way: pole twist (already §10s-six, 0.51→0.64); Full hand for T (0.525); pure 8 cm miss threshold (noise-regressed coup2/sit_end mid-band); moderate-miss and |aIk − aStored| > 0.25 hinge gate (kept the 0.23 win on coup_fort_03 but dropped praying/blush side-wins and left the same max reg).
Full-corpus A/B (3173 biped direct-ref, new default vs PMB_BIPED_IK_LP_HOLD=0): 3 improved / 3 regressed / 3167 same; mean worst 0.1488 → 0.1487; over-0.25 tol 589 → 588. Wins: coup_fort_03 0.5116 → 0.2294, emote_praying 0.114→0.061, emot_blush 0.172→0.154. Max regression +0.037 (l2m_coup2, noise-tier). Subset vs ARMS=0 retains the §10s-cinq strike/emote wins (coup_fort_03 now 0.23 vs arms-off 0.93).
Still open: residual ~0.23 on coup_fort_03 last plant is the FK floor (stored upper/hinge vs CS mid-plant); other strike files still foot-worst or LargePath-gated; in-plant foot rotation; blend ramps. Next lever is true mid-plant Max GT or a different EE path (not dual-fold re-bend).
Max 9 residual probe (gen_biped_ik_residual_probe.ms → ~/biped_ik_residual_probe/) landed Parts B–E; Part A failed first pass only because R:\graphics was not mounted (paths correct).
Storage pin (Part C --diff-rig): scripted k.ikAnkleTension does persist on Max 9 plant keys (unlike the older a_ik_ankle* dataset where set never stuck). Payload diffs:
| Pair | Real field |
|---|---|
c_ankle_t0 vs t1 |
0 → 1 |
c_ankle_t0 vs t05 |
0 → 0.5 |
c_ankle_base vs t0 |
noise only (0x0709) |
Chunk map: leg keytrack 0x013a floats [113] and [223] (4-float header + 110-float keys → record field [109] on key0/key1); live twin 0x025d at [219]. Same layout applies to R leg 0x0138 / arm tracks if written. +legShift on 4-link legs.
Shipped: m_ChAnkleTension[side] via scalarChannelFrom(..., 109 + legShift) — decode only, no solve apply (default accuracy unchanged). Corpus plants are tension≈0; first Part B samples were Body-space (ikSpace=0) so foot rode COM and t0/t1 eval were identical — not a tension-effect measurement.
Script fix for re-run: force Object space (ikSpace=2) after every plant key; strip stray trailing ) parse error; pure ASCII. Re-run with graphics mounted for Part A + Object-space Part B/C.
Part A re-run landed (~/biped_ik_residual_probe_new/, 2026-07-09): full write-up ~/max2010_biped_probe/RESIDUAL_PROBE_NEW.md + offline_data/partA_vs_export.json.
| Class | Live Max 9 vs our export (world rot qdist peak) | Takeaway |
|---|---|---|
| LargePath strike arms | hom_coup_fort_03 UA 0.30, hof_coup_fort_03 UA 0.17 / FA 0.06 / Hand 0.03 |
Wrist/forearm world OK mid last plant; upper-arm frame is the residual |
| Non-strike arms | ≤0.0004 | ε floor |
| Cheer foot long plants | 0 mid-plant (geodesic path, out≈0) | yaw-frame model matches Max 9 |
| Outside-geodesic short plants (coup_fort R) | ours 0.08–0.12 miss | open foot-rot class (not cheer) |
| Course / demitour foot | 0.04–0.12 | mixed contact/swing |
| ikAnkleTension | t0≡t1 samples | storage only; no eval lever on synthetics |
Next model priority: (1) LargePath upper-arm mid-interval — refined §10s-neuf: live-GT decomposition shows the residual is arm EXTENSION (Max holds the wrist at the constant stored-hinge reach; our models over-bend), NOT swivel (swivel is exact); a reach-fix experiment was net-negative on file-worst and not shipped, (2) short near-static outside-geodesic foot plants, (3) course contact. Do not default-on pole or ankle-tension apply.
Picked up the §10s-oct Part A re-run's #1 priority ("LargePath upper-arm/swivel mid-interval") with the new live Max 9 GT, decomposing the per-frame upper-arm world-rotation error into the parts an IK model can act on. The decomposition corrects the framing: the residual is NOT swivel.
Tool: cmp_gt_samples.py (pipeline_max_corpus_test). Aligns our pipeline_max_export_anim --dump-samples output against a Part A manifest CASE at quarter-frame and decomposes a 2-bone limb chain's world-rot error into: per-bone qdist (≈sin(θ/2), the OFFLINE geodesic relation), the UPPER-bone pointing tilt (shoulder→mid direction angle), the swivel (elbow-offset angle about the reach axis), and — with PMB_BIPED_IK_DEBUG — the solve target T's radial reach vs Max's wrist. Replaces the per-session ~100-line ad-hoc alignment scripts the §10s rounds kept re-deriving.
The decomposition on fy_hof_co_l2m_coup_fort_03 R arm, last LargePath [4640,5600] (frames 29–35), our default (FK-hold) eval vs live Max 9 GT:
Root cause. Character Studio's LargePath IK keeps the arm EXTENDED — the wrist holds a near-constant distance from the shoulder, which IS the stored hinge angle [0] (TCB-interpolated): the FK wrist reach tracks Max within ~0.01 m where the palm-pivot target T is off by ~0.06 m. The palm-pivot target T (W − handRot·pLocal, the §10r leg model generalized) tracks the weapon DIRECTION but its reach |T−shoulder| shrinks mid-interval (the TCB palm path + rotating hand pull the wrist inward). FK inherits the stored hinge's reach (right) but the wrong DIRECTION (the stored upper quat [2..5] doesn't sweep between keys); the palm-pivot target has the better direction but the wrong reach.
Reach-fix experiment (net-negative on file-worst; NOT shipped). Rescale the LargePath arm target radially onto the FK-wrist reach sphere (T = H + |ankFk−H|·normalize(T−H)): direction from the palm-pivot target, magnitude from the stored hinge. With the §10s-sept moderate-miss hold disabled (the reach-fix supersedes its over-bend premise), the dual-fold reaches the extended target — world UA pointing drops 0.42→0.33 rad at frames 30–32 and the wrist tightens (0.11→0.078 m), matching the |T_combined−Max| prediction (0.104 palm / 0.110 FK → 0.085 combined). But the file-worst metric (local track vs the reference .anim) regresses: the anim_ik_subset.py A/B (reach-fix off vs on, 259-file subset) reports reach-OFF better — mean 0.1872 vs 0.1915, coup_fort_03 0.23→0.93 (the forced extension exposes that the dual-fold's elbow FOLD still diverges from Max at the interval tail; the FA local track compounds to a large error there), 3 improved / 0 regressed for reach-OFF. The §10s-sept FK hold remains the better single approximation. Code reverted; PMB_BIPED_IK_LP_REACH was not kept (env-gated-off dead code with no path to default-on would only mislead).
Conclusion. The LargePath strike-arm residual is a 2-DOF mismatch (arm extension + the elbow fold that comes with it), not a swivel error. Closing it needs the full extended-arm IK solve Character Studio applies between keys (its hand-spline curvature + the derived elbow plane), which is beyond stored-data splines — the same wall as the in-plant foot rotation (§10r open item). The decomposition + cmp_gt_samples.py are the durable output; the next lever is biped.dlc IK-spline disasm (the offline suite's host_biped.cpp / disasm started this; ~/max2010_biped_probe/), not another wrist-target spline variant. Corrects the Part A re-run's "(1) upper-arm/swivel" to "(1) upper-arm/extension" — the swivel half is closed (exact).
A full pipeline_max_anim_corpus_vs2008 sweep (the §2b instrument, run for the user's "review and refine the floating-point precision behavior under exact Max 2010 build conditions" direction) confirms §10m's recorded findings hold and pins the byte-identity ceiling precisely:
1+60/61 EPS both). Exactly §10m's "the fauna optimized tier is genuinely cross-build-fragile, and that's a property of anim_builder's own thresholds" — informational only (not gated), and not a pipeline_max decode defect.Net: the VS2008 pass reproduces the x64 outcome on every load-bearing tier; the residual anim inaccuracy is the biped IK model (§10s-bis), unreachable by codegen matching. T1/T2 4452+71 byte-identical under x87 (the chunk parser/writer never recomputes a float).
The §10h-bis-style "review the anim export coverage in depth, including anything not currently tested for any reason" round. The enumeration audit came back clean; the reference audit did not — a whole direct-comparison tier existed unused, and wiring it in immediately surfaced three real decode gaps that every earlier gate was structurally unable to see. All three are closed; corpus numbers at the end.
Enumeration is complete — a perfect 1:1. Only three workspace projects run the anim process (common/{characters,fauna,sky} — verified against every process.py in the workspace), anim_corpus.py enumerates exactly their AnimSourceDirectories (4524 .max: 4452 biped + 71 non-biped + 1 git-lfs stub), and the reverse accounting holds both ways: every reference .anim (3174 characters + 1340 fauna + 4 sky) has a source in the enumeration, and every usable source has a direct reference. Nothing is silently untested at the file level.
The gap was the reference wiring, not the enumeration: ~/pipeline_export/common/{fauna,sky}/anim_export (1340 + 4 direct pre-optimizer references, from the same fresh Max 2010 1_export run as the characters tier) were never in the driver's --ref-direct list. Fauna — 30% of the corpus — was only ever compared through the informational post-anim_builder tier against 2004-era core4_data (whose optimizer tolerance masks value-level errors up to the key-drop thresholds, and whose comparison isn't even gated per §10m). Both are wired in now; every file in the corpus runs the gated direct tier and the optimized tier only remains as a fallback for setups without the fresh references. (The fresh anim/ post-builder outputs were checked too — byte-identical to core4_data, so the optimized-tier references needed no change.)
What the fauna direct tier caught — three decode gaps, all fixed:
CStorageBezScaleKey.OutTan was read at the wrong offset (floats [10..12] — always zero). The real 0x2528 layout after S[3]+Q[4] is four 7-float blocks at stride 7 — {InTan, OutTan, InLen, OutLen}, each vec3 + 3 zeros + a constant 1.0 tail — so OutTan lives at [14..16], InLen at [21..23] (−1 sentinels = default ⅓), OutLen at [28..30]. §10b's "provisional" placement survived every earlier gate because the character corpus carries zero scale tangents throughout; the plante_carnivore fauna refs are the first corpus keys with real tangents (pinned by matching stored floats byte-for-bit against pr_mo_phytopsy_attack's reference Box31/Box32.scale tracks). Roundtrip was never affected (raw bytes authoritative); only the export read view moved.addBoneTracks never re-entered the biped path on a nested foreign COM. The reference's addBoneTracks re-enters addBipedNodeTracks on any BIPBODY child, with the ONE CAnimationBuildCtx built over the whole selected subtree up front — so the mektoub_selle rider rig (Bip01male under selle-assise/selle inside the mount's subtree, its bones confusingly still named Bip01 *) exports through the same shared sample context. Ours dropped the entire rider subtree; the name-colliding bones resolved first-wins identically in the reference, so only the rider's uniquely-named bones (Finger3/4 chains, Ponytail2, its end-effector dummies, Bip01male itself) surfaced as the 2 structural fails. addAnimation now builds the sampled context once per selection (like the reference) and addBoneTracks re-enters on nested COMs; §10d's "biped COMs nested under selected non-biped nodes — no corpus file has that shape" conclusion was true for the characters corpus and false for fauna.Bip02.pos/rotquat are constant per file, and the constant equals the figure attach (position exact to ~2e-4 on every kitin anim; rotation to ~6e-3 — a small saved-pose twist, open). The stun trilogy (atk_stun_init/loop/end) is the one exception at 0.098 — a clean per-file cluster whose figure was re-posed after the reference export (reference-era class, max file authoritative). Rig-internal locals are invariant under the re-anchoring, so only the COM's own track changes. Full-corpus A/B: 55 improved / 1 "regressed" (stun_init joining its trilogy siblings at the honest era delta) / 4390 same.maxScaleValueToNel (srtm·stm·srtm⁻¹) diagonal is a hand-rolled port of the reference's Max SDK Matrix3 arithmetic, and the VS2008/x87 build moves the ULPs around without closing them (verified directly — same wall as decomp_affine, §10i/§10l: matching Max's own compiled operation order needs Max's object code, not a compiler). Same FLOATEQ policy as shape/skel/zone, two orders of magnitude tighter than shape's 2e-6.Corpus after the audit round (full 4524-source sweep, all gates green): non-biped 25 byte-identical + 46 floateq / 71 (fauna previously untested at this tier); biped 7 byte-identical + 4445 structural, 0 structural fails (was 2); worst-delta max 5.476 → 1.449 (now the mektoub rider pair — its ref attach is position-identity + canonical rot against the saddle, suspected default-vs-keyed parent frame for the attach, open), median 0.0929, over-tol 1216 (the fauna biped files are counted for the first time; the characters-only median stays ≈0.069). T1/T2 4452 + 71 green after the struct change (raw bytes authoritative, as designed). The shape-corpus side of the same audit: shape_corpus.py now compares produced per-node anims against the per-project ~/pipeline_export/<grp>/<proj>/shape_anim references (was: a basename-collision-prone core4_data glob) and accounts for reference anims not produced — a reference without a producer was previously invisible.
The shape-process anim tier is now fully accounted too: all 110 ~/pipeline_export/**/shape_anim references (48 transform-only + 47 texmat + 11 light-group + 3 empty + 1 morph, §10k-bis) are produced and 110/110 byte-identical under the per-project reference lookup — "0 reference anims not produced" is now a measured number, not an assumption (the old core4_data basename glob could only compare what we happened to produce).
VS2008/x87 verification of the new tiers (same session): the full sweep through ~/build_vs2008_wine_pipeline reproduces the biped tier to the digit (7 ident + 4445 structural, over-tol 1216, median 0.09293, max 1.449 — the nested-COM attach rule and the audit fixes are float-codegen-independent like the rest of the biped eval, §10m-bis) and shifts the non-biped split to 20 byte-identical + 51 floateq (x64: 25 + 46) — five files trade places between the ident and floateq tiers across codegens, exactly the predicted ULP shuffle of the maxScaleValueToNel class; the gate is green under both builds.
Open after this round: (a) the linked-COM attach's exact source RESOLVED §10m-quater (2026-07-10) — the probe ran and the attach is decoded exactly from stored chunks; the figure-attach rule of this section and the stun-trilogy "reference-era" claim are both superseded (the references were never stale — Kaetemi's correction prompted the re-check). (b) stun-trilogy era class withdrawn, decoded exactly (§10m-quater). (c) The kitin residual after the fix is ordinary IK-tier (worst 0.11–0.32 on run/atk files — the §10s classes).
gen_biped_linkcom_probe.ms ran in Max 9 (~/biped_linkcom_probe/: manifest + 8 single-variable synthetic saves), prompted by Kaetemi's correction that the stun-trilogy references were unlikely to be stale. He was right on every count; the whole §10m-ter attach story is superseded by an exact decode.
Probe facts (Part A, real files, read-only):
biped.getTransform ≡ node.transform on a linked COM (no channel-vs-node split) — the ridden TM is the only live value.Part B semantics (fresh biped linked to a keyed box, one variable per case): linking preserves the COM's world pose at link time (c00/c01); an ordinary move of a linked COM edits the stored local by the parent-frame delta (c02); a figure-mode move edits it too (c04); moving the parent afterwards changes nothing (c05 — rigid riding); keyed V/H/T does modulate the local over time (c03) — the real corpus files' locals are constant because their linked-COM channel keys are constant-valued (probe-verified), so a constant-local export is corpus-exact and the keyed-modulation general case is documented out-of-corpus scope (§12.2).
The storage decode (float-exact on all four probe files, position AND rotation):
L (the exported constant local) = P · C (NeL column convention: P * C)
P = chunk 0x0112 (12 floats: 3×3 rows + translation) — the INVERSE of the parent's
world TM captured when the link relationship was last established/edited,
stored in PLAIN world coordinates (row-vector; NOT Y-up — unlike the other
biped records). Identity on unlinked/root rigs. Was §10p's unknown 0x0112.
C = the current-position frame as a full TM: 0x0104's rotation with translation
0x0104.t + the 0x0260[0..2] correction vector — the FULL-VECTOR generalization
of §10o's HeightCorrection scalar (whose 0x0260[1] is this vector's Y-up
vertical slot). This UNIFIES §10o: the unkeyed root COM reads the same
corrected frame, just its vertical component.
Two traps burned into the implementation (biped_rig.cpp parseComRecord → SBipedRig::LinkParentInvTM/BaseFrameTM, consumed in pipeline_max_export_anim's nested-COM override): (1) converting the Y-up 0x0104 into a COMPOSABLE TM needs the full basis-change similarity B·Mᵀ·Bᵀ — NeL columns (I, −K, J) of the §10 item-1 per-vector forms — not the one-sided C·Mᵀ world-rotation rule (which is only correct when the input space is a bone-local frame that never gets converted); using the one-sided form produced position-exact but rotation-garbage locals, since the position path only exercises P's rotation. (2) 0x0112 itself needs NO conversion (plain world row-vectors → NeL columns via the standard rows-as-columns convertMatrix shape).
0x0117 corrected: on linked rigs the Move All chunk is NOT identity (§10o's "identity on every shipped corpus file" was measured on root rigs only — the kitin/queen linked rigs carry real rotations+translations there, and the synthetic link cases record the parent's key-range displacement in it). It is NOT part of L (all four files decode exactly without it); its linked-rig semantics stay unmapped and it must not be applied as a COM offset on linked rigs (the §10o consumer only ever fires for the root COM's unkeyed-vertical path, which linked rigs never reach — verified no corpus effect).
Corpus (full 4524-source sweep vs the §10m-ter figure-attach baseline): the four probe files' nested-COM tracks are float-exact (1e-7 tier); tr_mo_kitin_atk_stun_loop file-worst 0.098 → 0.080 (the "era" number was our decode error), mektoub pair 1.449 → 1.32 (their remaining worst tracks are the mount's 4-link HorseLink/Toe IK tier, a pre-existing class), queen 2.6-era → 1.2 (its known outlier thighs). Gates green; A/B: only linked-COM carrier files move, everything else bit-identical.
The §10h-bis/§10m-ter-style "review the shape export coverage in depth, including anything not currently tested for any reason" round. Enumeration audit clean; the reference audit identified all 13 not-produced references by name and closed 12 of them with three real decodes; two long-open items (UVW Map planar, Physique cross-links) closed off corpus GT found along the way; one harness race and one silently-failing ctest gate fixed.
Enumeration is complete. 26 of 42 workspace projects run the shape process; every ShapeSourceDirectories entry resolves on disk except 4 that are genuinely absent from the checkout with correspondingly absent references (tryker/matis/zorai agents/actors/bots — empty dirs don't survive git; fyros' exists and carries only an animation/ subdir — and newbieland's landscape/water/meshes/jungle/newbieland; newbieland's single reference shape comes from its sky dir, which resolves). 2850 enumerated .max, T1/T2 2850/2850 green at baseline. Nothing silently untested at the file level.
The 13 not-produced references, identified by name and closed:
shape01..07, rectangle02 in common/characters from ma_hom_armor04.max/ma_hof_armor04.max; rectangle01 + tr_wea_hache2m_trail_00 in common/sfx from mag_impact_cold.max/tr_wea_hache2m_trail.max; fy_hom_interfaces_new also armor04) — the reference converts Shape-superclass nodes to TriObject: open splines → EMPTY CMesh (0 verts / 0 faces / 0 used materials), closed splines → a capped mesh. The §10z-dix "reference also produces no mesh .shape for plain splines" assumption was WRONG — real references existed for all of them. The cap algorithm was reversed face-for-face from the references (61 triangles, zero deviation): min-interior-angle valid-ear clipping in the XY projection, last-min tie-break, reversed ring for negative-area polygons, cap failure (self-intersecting XY projection — the shape02/03 class, which real Max 2010 exports EMPTY) → empty mesh. Two format corrections landed with it: the spline CLOSED flag is 0x290d, not 0x2904 (open trails 0, closed outlines 1; du = 1/n closed vs 1/(n−1) open corroborates), and 0x1050 = interpolation steps (0 on every closed corpus outline — why caps have exactly one vertex per knot). Rectangle (0x1065) generates knots from pblock length/width/fillet per the SDK sample source. New shared pipeline_max_export_common/spline_mesh.{h,cpp}; format write-up in max_geometry_formats § N.5. Result: 8/8 armor04-family shapes FLOATEQ on first comparison, trail + rectangle01 FLOATEQ (rectangle01 after the UVW planar landing below; rectangle02's residual is its Edit-Mesh-created-verts class, base 4 corners exact).fy_smoke_water (continents/fyros, from fy_cn_smokehouse.max) — a CWaterShape whose surface node is a Plane, a Max 4+ class storing a ParamBlock2 instead of the old-style pblock extractParametricPrimitive expected (the §10z "single file failing on missing required maps, matching reference NULL-return" note was wrong — a 511-byte reference exists). PB2 constants translate to the same index-keyed param map (Plane: 0 length, 1 width, 2/3 segs — verified 8×8 against the reference polygon). Now FLOATEQ.ge_hom_acc_gauntlet_kami — the known skinned-maxverts authoring case (§10z-huit, defective_max_files §1.7). Stays the single not-produced reference.UVW Map planar — corpus-validated and DEFAULT-ON (272 uses → 81 gated non-planar). Rectangle01 turned out to be the pinned GT the gated scaffold was waiting for: its 4-vert cap has known base geometry and known reference UVs. The stored pblock dims ARE the Fit gizmo size (width 48.0152664 = bbox·1.001 — the margin is baked by Max's Fit, not a formula to reproduce), and the planar sign convention is u = 0.5 − p.x, v = 0.5 − p.y under tm = ctx · Inverse(gizmo-with-dim-scale) (artist-Fit gizmos store a ~180°-about-Z rotation; the reference's sub-ULP UV shear is that quat's float-π residue). FLOATEQ on the GT; corpus A/B net-positive (floateq 1056 → 1073, differ 1043 → 1038 while +12 new files entered the comparison). Non-planar types stay gated pending their own validation (PMB_UVW_APPLY=1 probes them); format write-up in Part O.
Physique cross-links decoded — positive boneRefs are 1-BASED indices into the modifier's reference table (Part M §M.3 item 1b closed). The M.2 "different record structure" hypothesis was falsified by a whole-corpus record-size scan (every 0x0989 record is exactly 4+20·numBones bytes); the cross-link records pair (−83 → idx 82 'R Thigh') with (+84 → idx 83 'R Calf') on knee vertices, anatomically exact, and the 1-based read makes fy_hof_armor01_pantabottes's used-bone set match the reference exactly (8 bones; the 0-based fallback had produced 9 incl. a spurious R Foot). Position-matched per-vertex comparison against the reference CMeshMRMSkinned: every matched vertex weight-exact. Remaining unresolved refs on 2 of the 5 armor01 nodes (24 + 141 entries) are the next quality item; the vertex-count-mismatch class (Physique authored against a different stack state) also remains.
Harness fixes (the broken-gate class again):
veget_corpus.py's T3 counting was nondeterministic under -j — the shared per-eco outdir with before/after dir-listing snapshots raced, attributing outputs to several files at once: parallel sweeps reported 796 exported vs 126 serial, varying run to run. Per-file outdirs fix it. The §10z-onze veget record ("505 exported: 92+397+16") was that artifact — the true, now-deterministic state is 126 exported: 25 byte-identical + 97 size-eq + 4 size-diff, 0 not produced. File-level A/B against HEAD binaries confirms this session's changes touch only 3 already-diff veget files. A pipeline_max_veget_corpus ctest gate now exists (none did).pipeline_max_shape_corpus's budget had been failing silently since the §10z-sept skinned landing (+601 DIFFs against a never-raised --max-diff 400). Re-baselined to measured state (min-identical 2000 vs measured 2083 good; max-diff 1400 vs measured 1387) with the tighten-as-classes-close convention.pipeline_max_shape_corpus_vs2008 wired (the §2b instrument for the user's standing precision directive), self-skipping like its skel/anim/zone siblings. The VS2008/Wine tree rebuilds cleanly with this session's C++03-safe additions.New diagnostics: --dump-mesh (full VB values + rdrpass index triples — the GT extractor the cap/UVW reversals ran on), --dump-skin gains positions + PMB_SKIN_DUMP_ALL, PMB_SPLINE_DUMP (knots + object chunk tree), PMB_UVW_DUMP (pblock/gizmo/ctx/tm), PMB_PRIM_DUMP (PB2 prim params).
Corpus after the round (full 3590-source sweep, x64): 3589 exported (was 3577): 94 byte-identical + 1073 float-noise-eq (was 1056) + 1038 differ (was 1043) + 118 mapext + 916 lightmap-verified + 349 lightmap-diff + 1 no-ref; not-produced 13 → 1 (the gauntlet); skip classes reduced to skinned-maxverts=1 alone (mesh-eval and water GONE); export failures 0; material anim 110/110 byte-identical; T1/T2 2850/2850. Coverage of the reference shape set: 99.6% → 99.97%.
Continuation of §10z-douze's Physique thread, closing Part M §M.3 items 1(b) and 2 with corpus data alone (the planned differential dataset was never needed):
Skinned meshes export their vertices in WORLD space. Reference export_mesh.cpp:671: ToExportSpace = GetObjectTM when skinned (the runtime deforms by skin weights, not the node transform); DefaultPos/Rot/Scale stay the node's local transform. Ours used objectToLocal for everything — which is why no skinned vertex ever position-matched its reference (the discovery came from a position-matched weight comparison whose matches turned out to be only degenerate origin verts — MRM VB entries beyond the loaded lod — a second broken-oracle instance in one session; the fixed oracle matches 1869/1871 input verts).
The Physique boneRef decode, fully solved: stored value = LINK index k (negative k = ~stored rigid, positive k = stored − 1 deformable cross-link — every 0x0989 record is uniformly 4 + 20·numBones bytes, refuting M.2's "different record structure" hypothesis). Link k = the segment ENDING at ref[k+1]; the vertex deforms by the segment's START = the INode parent of ref[k+1], not ref[k]. Mid-chain the two coincide (parent(ref[k+1]) == ref[k]) — why the §10z-six read looked anatomically right — but at chain boundaries only the parent rule matches: the Hand-slot link deforms by the Forearm, a chain-start slot (right after a NULL attach) deforms by the Hand, a nub slot by the last real chain bone. Solved by weight-multiset matching over every position-matched vertex of the five fy_hof_armor01 nodes (1869 verts, 100 % bone agreement; residual deltas bounded by the reference's uint8 weight quantization). All unresolved-ref classes drop to zero; the NULL slots turn out never to be addressed by valid links (they sit at k+1 of chain-attach links); weighting Bip01 itself is unrepresentable (k = −1), consistent with the reference's root promotion being purely ConvertToRigid's doing.
Corpus after this round: byte-identical 94 → 122 (whole skinned CMeshMRMSkinned files now reproduce byte-for-byte — the quantized packed format makes byte-identity reachable where float meshes sit at FLOATEQ), differ 1038 → 1005, floateq 1073 → 1079; everything else unchanged (T1/T2 2850/2850, material anim 110/110, export failures 0).
MRM-class compare field walk (same session). compareShapesFields previously raised DIFF unconditionally for any non-byte-identical CMeshMRM/CMeshMRMSkinned (~600 files, no field diagnosis). The walk compares bones, skin weights (±1 uint8 quantum for the packed class), the dequantized VB, and the per-lod rdrpass/index structure; same-topology float-noise output → FLOATEQ, structural divergence stays DIFF with precise counts. floateq 1079 → 1209, differ 1005 → 875. Ctest budgets tightened to the measured state (min-identical 2200, max-diff 1250).
End-of-session DIFF classification (signature group-by over the full compare output — the §10i working-method script, now excluding the lightmap-shader class properly):
*_hand_fp, armor06/armor04 pieces — norm maxAbs ~0.3 at borders, positions already within ~1e-4 world-accumulation noise): the INTERFACE_FILE border normal weld (applyInterfaceToMeshBuild), still unimplemented. The biggest single actionable class now.VS2008/x87 pass (milestone-1 code): 94 ident + 1043 floateq + 1068 differ — vs x64's 94 + 1073 + 1038. The FLOATEQ tier is codegen-stable (±30 border files shuffle, same as anim §10m-ter's ULP shuffle); byte-identical identical. Confirms the shape residual is model/decode classes, not codegen. pipeline_max_shape_corpus_vs2008 is wired as a standing gate; the VS2008 tree rebuilds cleanly with this session's code (C++03-safe — one transient link failure when rebuilding while a Wine sweep still held the exe open) and the end-of-session x87 sweep with the final binaries came back marginally BETTER than x64 — 126 byte-identical + 1215 floateq + 865 differ (x64: 122 + 1209 + 875) — the zone-precedent shape of x87 closing a handful of borderline files; both builds pass the re-baselined gate budgets. A used-bone tie-order probe confirmed the skinned bone-order divergence is DOWNSTREAM of MRM vertex reordering (our own first tie vertex emits Spine first, yet the used list has Pelvis first), so it is part of the MRM-structural class, not an independently fixable input ordering.
Handoff (priority-ranked): (1) interface-mesh border weld (~75 files' normals + unblocks armor byte-quality); (2) Morpher blend shapes (10 visage files export the wrong shape CLASS + the M3 morph item); (3) CMeshMultiLod compare walk + slave-material merge; (4) UVW Map non-planar types (81 gated uses; cyl/sph/box need their own GT — the corpus itself can pin them the way Rectangle01 pinned planar); (5) Unwrap UVW (38) / FFD (20) / EditablePoly map channels; (6) the used-bone tie-order + MRM structural chase (biggest count, lowest per-file impact — bounded float-noise divergence).
The §9-pre-triaged Map Extender class (mapext198m3.dlm, Class_ID (0x2ec82081, 0x045a6271), 87 files / 118 exporting nodes / 164 mod-app instances) is not a missing-projection problem: the plugin object stores no settings (empty 0x39bf → 0x0100 on every instance), and the computed UVW map is saved flat in the OSM LocalModData cache. Format write-up: max_geometry_formats Part P; defective-files note §4.4 updated.
What we don't have, and don't need. The plugin DLL is not on this machine (no offline-disasm channel). The plugin object has no pblock, no gizmo, no channel override to reverse-engineer — 140/159 objects are only the empty 0x39bf pair; the rest add only universal ReferenceMaker/AppData chunks. Remaining 0x2512 siblings beyond the four count/vert/face records (0x03ec edge flags, 0x03ed–0x03fa selection/named-sets, 0x044c) are Mesh-format companions and are ignored for export.
What the cache holds. 0x2500 → 0x2512 (leaf payload, still a chunk stream) → 0x03e8 nVerts / 0x03e9 Point3 UVW / 0x03ea nFaces / 0x03eb face corners / 0x03f3 channel (1 or 2). 164/164 instances carry a complete functional set; UV values are UV-space (not object XYZ); face count matches the mesh at the modifier's stack position on 160/164 applies. The 2012 pipeline_max_rewrite_assets ReplaceMapExt mode had already lifted 0x03e9/0x03eb into Unwrap UVW form — this session promotes that decode into a reusable library rather than a one-shot rewrite.
Implementation. Shared pipeline_max_export_common/map_extender_mod.{h,cpp} (MAPEXT::readMapExtenderCache / applyMapExtender); wired into pipeline_max_export_shape/mesh_eval as a replacing map-channel write (same consumer shape as UVW Map). Handles 0x2512 as either raw leaf or typed container. Face-count mismatch refuses the apply. (4 primes_racines sky domes: stale cache, correctly skipped) Corrected §10z-quinze: those 4 refusals were OUR defect (the base Sphere's ignored hemisphere parameter), not stale caches — the caches were valid and apply on all four after the hemisphere path landed; no genuinely-stale mapext cache exists in the corpus.
Corpus. Full 87-file mapext sweep: 114/118 MAPEXT-tagged export nodes apply cleanly (118/118 since §10z-quinze — the 4 sky refusals were the hemisphere defect); total shapes still export. Harness keeps the MAPEXT tag and the dedicated T3 bucket — the reference UVs remain garbage (§9), so UV equality against the reference is still meaningless; our recovered UVs are the correct ones. No T1/T2 surface (library is export-side only; no new typed scene class).
Answer to the open question ("internal cache with functional chunks" vs "stores the map flat and remaining chunks are other plugin settings"): both halves, sharpened — the map is stored flat in the mod-app cache (not re-derived from plugin settings, because there are none), and the remaining 0x2512 chunks are Mesh-format companions / selection state, not alternate projection parameters.
The second "review the shape export coverage in depth, in particular the Map Extender plugin" round (continuing §10z-douze/§10z-quatorze). Five landings, ending with every corpus gate at or above its documented floor and shape byte-identical at its best-ever count.
Interface-mesh border weld (commit aca9ffee5) — the §10z-treize handoff item (1), the reference's CExportNel::applyInterfaceToMeshBuild (plugin_max/nel_mesh_lib/export_mesh_interface.cpp) replicated as pipeline_max_export_shape/interface_build.{h,cpp} (IFACEBUILD). Nodes with NEL3D_APPDATA_INTERFACE_FILE name another .max whose meshes are flat border polygons; every exported vertex within INTERFACE_THRESHOLD of an interface vertex (world space) is border-welded. The MRM bookkeeping fills either way (CMeshBuild::Interfaces/InterfaceLinks/InterfaceVertexFlag + the CMeshMRMSkinned packed-vertex pack/unpack align that snaps linked positions onto the packed grid); normal correction has the two reference variants gated by GET_INTERFACE_NORMAL_FROM_SCENE_OBJECTS (corpus: 1 on 265/286 instances — scene-normals dominant): 0 → snap position to the interface vertex + its polygon-edge normal (prev/next edge × avg poly normal); 1 → area-weighted sum of ALL scene faces sharing a smoothing group with the corner's face and having a vertex within threshold (positions do NOT snap). Interface .maxes resolve through DBPATH (authored R:\graphics\...), border polys extracted by single-ref-segment loop ordering, scenes cached per path; the interface-to-world matrix is Identity for skinned meshes (their VBs are already world, §10z-treize) else toWorld · fromExportSpace. Scene-normals candidate faces are gated to evaluable classes (EditableMesh/EditablePoly/prims/SHAPE splines — Biped Object 0x9125 boxes are excluded, a known candidate-set gap for the residual below). Corpus effect at its landing: byte-identical 122 → 139, floateq 1209 → 1285, differ 875 → 782. Residual: a normals class where our welded normals sit ~0.002–0.2 off (pantabottes 41-comp/0.002, hand 168-comp class) — suspected missing biped-box candidates and/or the reference's RVertex merge order at borders.
Mapext verification tier (same commit) — --compare-mapext-mask + uvMask() in the exporter compare: for Map-Extender-tagged shapes the reference's UVs are garbage (§9 pre-triage) which poisons its vertex dedup, so the mask compares everything EXCEPT UV values / vertex sets / index content (weights-count and lod index-count gates ride under it; index content non-fatal). shape_corpus.py routes the mapext bucket through it, splitting the flat bucket into mapext-verified vs mapext-diff — same upgrade the lightmap bucket got in §10y. At landing: 62 verified + 55 diff; end of session 69 + 47.
Sky-dome hemisphere (commit a3e09a016) — the 4 primes_racines sky domes were the mapext stale-cache refusals (§10z-quatorze): their base Sphere carries hemi = 0.5 (old-pblock param 3), ignored by PRIMMESH::buildParametricMesh → 960 faces built where Max builds 576, so the whole Edit Mesh chain landed on wrong topology and the cache (486 faces) correctly refused. PMB_STACK_DUMP (per-modifier face counts) + delta arithmetic pinned it (576−96+4+2 = 486 at the modifier, −2 = 484 = reference exactly) before any code changed. Implemented per the SDK sample source (sphere.cpp, non-pie): rows = int(hemi/delta)+1, last ring altitude forced to hemi, float alt/secang accumulation, bottom cut disc at z = cos(hemi)·r with smG 2 / matID 0 (the full sphere's cap is smG 1 / matID 1), squash (delta = 2·hemi/(segs−2), rows = segs/2−1), recenter (base-to-pivot shift, full-sphere path included), texture v advancing by basedelta per row INDEX (the SDK's genUVs never clamps the last row — the texture squashes uniformly). The corpus-validated full-sphere path is untouched (the SDK source also explains its +Y start: the legacy A_PLUGIN1 flag sets startAng = π/2). All four sky domes: base 290v/576f → cache applies at 486 → final 484 = reference, FLOATEQ (positions maxUlp 2). The mapext class is now fully closed: 118/118 tagged nodes apply the cache (§10z-quatorze's 114 + these 4).
Created-vert clone decode (commit 5cc394da5) — the sky domes' residual (4 verts off by ~radius, |delta| = 128.4 per vert in varying directions) exposed that §10w/§L.2's "0x0130 srcTag = authoring history, ignored on evaluation" was wrong. The record is the modern merge of legacy TOPO_CVERTS_CHUNK clone-sources with the clone's move: srcTag == -1 → fresh vertex, Pos absolute; srcTag != -1 → CLONE of input vertex srcTag, Pos = offset from the source vertex's PRE-move position (legacy effect order §L.6: clones step 3, moves step 13; corpus-proven by zo_bt_hall_reunion_vitrine's 25 clones-of-moved-sources landing exactly one move-delta off under post-move resolution, and fy_hall_reunion's single one doing the same). Closes the whole §10x/§10z-ter "created-vertex positions offset" class: FY_hall_reunion.cmb and Zo_bt_hall_Reunion_vitrine.cmb are byte-identical (were 16 verts ~10 units / 25 verts + 221 faces off); cmb direct = 12 ident + 3 float-eq + 1 differ (only Zo_bt_Hall_Conseil's unrelated rotation class remains; DIRECT_DIFF_BUDGET 3 → 1, ligo 105 → 95).
decompAffine "gold alignment" reverted — corpus over offline gold (commit 33286be98). Re-running the ig gates (first time since 2026-07-09 midday) found ligo-ig at 47 diffs against its 8-budget and ig processed-ref at 36+4 (was 40/0) — bisected to 94ddd9c08 ("Align max_math decompAffine with Max 2010 core.dll gold", an offline-probe session that never re-ran the ig gates). The regression is structural, not ULP: zo_acc_ascenseur_ext1's Scale decomposed to (1.1291, 1.0909, 0.9709) where the reference (real Max 2010 decomp_affine on the real node matrix) says (1.22, 1, 0.9709) — the transposed adapter smears the polar stretch across axes on rotated+scaled composites. The offline probe validated reassembly (|M − R·S·T|), which is convention-invariant and cannot discriminate the transpose duality; the shipped references can, and they refute it. A det<0 (mirrored-node) hybrid was also tested and falsified. What the gold adapter HAD bought — byte-identity on ~45 characters CMeshMRMSkinned (and the shape corpus's 122-ident tier of §10z-treize, measured after that commit) — decomposed to pure signed zeros: its trailing qtConj turned exactly-zero quat vector components into −0, matching the reference's (−0,−0,−0,−1) DefaultRotQuat byte pattern on identity-rotation nodes (the §10z-dix remanence / §10z-water 3-byte class, finally explained). Since conj(qtFromMatrix(Qᵀ)) is bit-identical to the rows-as-rows q for every nonzero component ((a−b) vs −(b−a) is float-exact), the equivalent fix on the corpus-correct adapter is two lines: exact-zero x/y/z → −0 (numeric consumers see −0 == +0; only raw byte compares care). The probe session's quatToMatrix3 normalization is kept (corpus-neutral). Also spot-verified: tr_water_00 drops from 3 to 2 differing bytes (the remaining pair is a genuine double-cover representative flip at w≈0, not zeros).
Corpus at end of session (all gates green, measured): shape 141 byte-identical + 1317 float-noise-eq + 749 differ + 69 mapext-verified + 47 mapext-diff + 984 lightmap-verified + 281 lightmap-diff + 1 no-ref; 1 not-produced (the gauntlet); 0 export failures; material anim 110/110 (previous session tier: 139/1285/782/62+55 — every bucket improved). ligo-ig 71 + 458 + 8 (budget 8), ig 54/54 field-exact + 40/0 processed, cmb direct 12+3+1, cmb ligo 34+95+92 (budget 95), zone 1068+130+3 (882 flips), skel dpos 4420+4099 / drot 11059+3 + non-biped 222/222, veget 25+97+4, anim biped 7 ident + 4445 structural (0 structfail, median 0.0928, max 1.411 — the mektoub-pair number shifts with the decomp revert; the linked-COM decode itself is unaffected) + non-biped 25+46/71. Shape ctest budgets tightened to min-identical 2450 / max-diff 800.
Same-day follow-ups (commits 61e2...-era):
Mapext-mask refinements (compare-side, all gated under uvMask()): bones compared as a sorted SET (first-use ORDER follows the reference's UV-poisoned VB dedup; set mismatches stay fatal), the LightMap-shader material masking extended from lightmap-only to either UV mask (27 of the 47 mapext-diff files were gen_bt construction mapext+LightMap combos), per-vertex skin-weight compare skipped (needs matching vertex order). Mapext bucket 69+47 → 107+9.
Physique single-link records are RIGID — the per-bone float is Physique's rigidity/blend factor (Part M §M.2), not a skinning weight; a one-link record deforms 100% by its bone. The c03/monster family stores 0.0 there, so the decode's non-positive filter dropped the only influence and 620+ verts/mesh fell to the root fallback (the tr_mo_c03_boss "bones 12 vs 55" class). Forcing 1.0 on one-link records is strictly additive (positive stored values already normalized to 1.0). Full sweep: floateq 1328, differ 738, mapext 109 verified + 7 diff.
Morpher blend-shape targets (bsList) landed — getBSMeshBuild replicated: per non-NULL Morpher ref 101+i the TARGET node is evaluated through the non-skinned mesh path with finalSpace = the SOURCE node's NodeTM when skinned (buildMeshInterface gains an optional morphFinalSpace right-multiplied onto objectToLocal, the reference's literal composition — which equals the source's world frame, since objectToLocal·nodeTM == objectTM for the source itself); interface-vertex corner normals copy from the welded export buildMesh; a non-empty bsList forces the CMeshMRM branch through the isCompatible gate (how the reference ships visage files as CMeshMRM-with-SkinWeights). Targets that fail eval or diverge in vertex count are dropped with a warning (NL3D's buildBlendShapes asserts equal counts). The 4 visage class-mismatch files land FLOATEQ; mapext bucket ends the session at 113 verified + 3 diff (was 62+55 at the tier's landing, 69+47 after the hemisphere/clone round): the 3 remaining are zo_hom_acc_gauntlet (rdrpass count) + 2 CMeshMultiLod slave-material merges. Session-final full sweep (x64): 141 byte-identical + 1330 float-noise-eq + 736 differ + 113+3 mapext + 983+282 lightmap; export failures 0; material anim 110/110. VS2008/x87 pass with the same final binaries: 158 byte-identical + 1354 floateq + 695 differ (same mapext/lightmap splits, 0 failures, 110/110 anim) — the reference codegen again closes a tier of borderline files (+17 ident / −41 differ vs x64, the §10z-treize pattern), and both builds pass the re-baselined gate budgets.
Handoff (updated):
materials: 3 vs 4 lightmap-tier class) and zo_hom_acc_gauntlet rdrpass-count.With the cache decode proven (§10z-quatorze) and the whole mapext class validated (§10z-quinze, the §4.4 per-node matrix), the missing plugin itself was rebuilt as a drop-in replacement: nel/tools/3d/plugin_max/map_extender/ → mapext198m3.dlm (output named exactly like the original so the corpus files' DllDirectory entries bind without a missing-plugin prompt). Re-registers Class_ID (0x2ec82081, 0x045a6271) / OSM; pure Max SDK (no NeL libs, no UI); builds clean in the VS2008/Wine Max-2010 plugin tree (~/build_vs2008_wine, the WITH_NEL_MAXPLUGIN tree — env exports per the VS2008 wiki page). Install = copy bin/mapext198m3.dlm into the Max 2010 plugins/ directory.
0x39bf{0x0100 empty} object stream is exactly what the Max SDK's own Modifier::Save writes — verified by dumping a corpus nel_vertex_tree_paint object (our plugin whose Save source we have: its stream is the same 0x39bf pair followed by its own version chunk). So the original's Save was literally Modifier::Save(isave) alone, and the replacement does the same — byte-compatible saves for free.setMapSupport + full map-vert/map-face replace, refuse on face-count mismatch — the corpus-proven semantics of the headless MAPEXT::applyMapExtender, reference-validated in §10z-quinze (105/118 references garbage-UV, 8 valid-and-matching, §4.4 matrix). Selection state is NOT applied (pass-through): nothing in the corpus evidence shows the original gating downstream modifiers on it, the reference exports validate without it, and the stored selection chunks stay available (raw-preserved) if a future case proves otherwise — §12.2.~/mapext_probe/manifest.txt, all 118 nodes PLUGINSTATE ok / class Map_Extender) and mapext_plugin_check.py verifies every node's at-position map channel is identical to its stored cache (corner indices exact, UVs within 1e-6 of the 9-sig-digit prints) — the drop-in replays the cache verbatim in live Max. Two harness defects fixed on the way, both self-diagnosed by the manifest: the script's first revision called getclassid, which is NOT a MAXScript built-in (SDK-side concept; replaced with the repo-convention classof string compare — a missing plugin reports Missing_OSM so PLUGINSTATE still distinguishes), and the checker's first parser kept only the LAST ATPOS CHAN record while dumpChannel emits every supported channel — 23 nodes with a second map channel spuriously failed against the wrong channel's pass-through dump. gen_mapext_plugin_validate.ms (pipeline_max_corpus_test; read-only) loads all 87 files, and per MAPEXT node dumps the map channel evaluated AT the modifier's stack position (modifiers above temporarily disabled, restored after) plus the full-stack view; mapext_plugin_check.py verifies the at-position dump is identical to the stored cache (pairing by (nFaces, nVerts); corner indices exact, UVs at 1e-6 off 9-sig-digit prints). The checker is verified end-to-end against a synthetic manifest generated from a real cache. The PLUGINSTATE line records the resolved class so a run against the missing-plugin stand-in is distinguishable (the stand-in reports the original ClassID via getclassid but produces no map channel — those FAIL loudly, as they should).Fresh instances: applying the modifier to a new node in Max is a documented no-op (no authoring path — the replacement exists to evaluate legacy data, not to recreate the original's projection editor).
Error-126 load failure on real Windows — fixed (same day, commit nel.cmake). The first packaged plugin set failed to load in Max 2010 ("nelexport_r.dlu failed to initialize, error 126"): every binary from the Wine trees shipped WITHOUT its VC90 SxS manifest — the toolchain disables CMake's mt.exe embedding, and mt.exe under Wine turns out to page-fault outright (plain -outputresource, not just the /notify_update contract previously documented). On real Windows MSVCR90/MSVCP90/MFC90 resolve ONLY through WinSxS manifests, so LoadLibrary fails with "module not found" even with the DLLs present in the redist. Fix: NL_DEFAULT_PROPS embeds the dependent-assembly manifest through the resource compiler (RT_MANIFEST type 24, id 1 EXE / id 2 DLL — rc.exe works fine under Wine), gated on NL_EMBED_SXS_MANIFEST_RC (set by the Wine toolchain file; real-Windows builds keep the mt path). Manifests reference RTM 9.0.21022.8 (publisher policy upgrades); MFC added via _AFXDLL directory detection or the NL_SXS_MANIFEST_MFC target property (object_viewer needs the property — its MFC ADD_DEFINITIONS runs after NL_DEFAULT_PROPS). Verified across the whole rebuilt plugin set.
quick_start inclusion (same day): all nine tool/quick_start/win32/install/redist/nel_plugin_max_*.bat scripts (Max 9 x86 … 2023 x64) now copy mapext198m3.dlm into plugins/, and the source carries the same MAX_VERSION_MAJOR guards the other plugins use for the SDK signature drift (NotifyRefChanged at 15, localized-name overloads at 24) — compile-verified on the 2010 SDK, the only one present here; the guarded branches mirror nel_vertex_tree_paint's, which builds across the whole range. Any WITH_NEL_MAXPLUGIN configure builds it via plugin_max/CMakeLists.txt, and the CMake install layout (maxplugin/plugins/) carries it too.
pipeline_max_export_clod replicates NelExportLodCharacter (build_gamedata processes/clodbank, maxscript clod_export.ms → CExportNel::buildLodCharacter in nel_mesh_lib/export_lod_character.cpp → serial of NL3D::CLodCharacterShapeBuild).
Surface closed. This was the last MISSING entry point on the whole Max-side export surface (coverage table A.4 / Session 5). Prerequisites already landed: PHYSIQUESKIN weight-exact since §10z-treize, shared shape mesh path (scene_lib / mesh_eval / material_build / mesh_build).
Path (reference-faithful):
NEL3D_APPDATA_CHARACTER_LOD == "1" (not DONOTEXPORT / RklPatch / nel_ps / pacs).evalNodeMesh + buildMeshInterface(..., skinned=true) → world-space VB (§10z-treize).PHYSIQUESKIN::applyPhysiqueSkinning → SkinWeights + BonesNames + PaletteSkinFlag.CMeshMRM::build with NLods=1, Divisor=1 (no poly reduction — the historic simpler-than-CMesh path).CLodCharacterShapeBuild::compile → serial NEL_CLODBULD.Corpus (clod_corpus.py --all --gate-t3, ctest pipeline_max_clod_corpus, self-skip 77):
stuff/lod_actors/lod_fauna (104) + lod_characters (2) = 106 .max; refs 103 fauna + 2 characters = 105 .clod under ~/pipeline_export/common/{fauna,characters}/clod_export.--min-floateq 84 --max-diff 21 --max-not-produced 0.Defect fixed this session — deep OSM Derived peel. go_mo_cococlaw_lod / tr_mo_cococlaw_lod failed mesh eval with base class = OSM Derived: the node carries a 20+ deep chain of nested OSM wrappers before the Editable Mesh (EDITTRIOBJ_CLASS_ID 0xe44f10b3). baseObjectOf's guard was 16 and SuperClassId of intermediate OSMs is often 0 (unknown-class path), so the peel stopped on an intermediate wrapper. Fix in shared scene_lib.cpp: guard 256 + seen-set cycle break + allow-list base refs by GeomObject/Shape/OSM/WSM/XRef ClassId (not "any non-modifier"). Both cococlaw files now size-match their references and land in the FLOATEQ tier. Shape exporter benefits too (same helper).
Residual classes (21 files, documented — not coverage gaps):
PR_MO_phytopsy_LOD — systematic U shift ≈1.019 across all verts (likely UVW offset/material channel not applied on this lod).Not exported (by design, maxscript parity): tr_mo_sagass_lod.max — its CHARACTER_LOD-flagged node has no Physique (the real skinned lod is tr_mo_sagass_selle_lod.max → TR_MO_sagass_selle.clod, which exports). Reference has no separate sagass_lod.clod.
VS2008/x87: not re-swept this session; residual is structural (bone names, index sets), not ULP-tier — codegen will not close the 21. FLOATEQ is the equality tier (positions bit-exact on field-eq files; normals ≤1e-7; skins ULP).
Handoff: surface completeness ✅. Quality tail on .clod shares the PHYSIQUESKIN ConvertToRigid item with shape; phytopsy UV is a one-file follow-up; normals are sub-threshold for shipping. All four residual classes attacked next day — see §10z-clod-bis.
The §10h-bis/§10m-ter/§10z-douze-style "review the clodbank export coverage in depth, including anything not currently tested" round. Enumeration re-verified complete (only common/{fauna,characters} run the clodbank process; ClodSourceDirectories = stuff/lod_actors/lod_{fauna,characters} = 104 + 2 .max, refs 103 + 2, a perfect 1:1 with tr_mo_sagass_lod's by-design non-export unchanged). The §10z-clod residual then went 21 → 6, every close decoded from corpus data alone (the per-vertex oracle: position-match our verts to the reference's, compare bone-name-level weight assignments — ~40-line python over parse_clod, same method §10z-treize used for shapes). Both corpus gates (x64 + VS2008/x87) wired and green.
The ConvertToRigid "root fallback" class was never a ConvertToRigid mystery — it was three undecoded link-tree boundary cases, all corpus-pinned per-vertex (12 files closed, physique_skin.cpp):
boneRef = 0 is the ROOT-ATTACH link (k = −1) — positive form k = stored − 1 applied to 0 — and it deforms by the link-tree root bone: ref[0] when non-NULL ('Bip01 Pelvis' on every biped-attached rig), else the INode parent of the first non-NULL ref (C04's table starts [NULL, 'Bip01 Spine', …] with Pelvis absent from the refs entirely — the reference still deforms those verts by Pelvis = parent(Spine)). Never the COM.ref[k+1] on a k ≥ 0 link (a deleted/free chain-attach end — diranak/estrasson's [-19] links end at the NULL slot ref[19], refuting §10z-six's "NULL slots are never addressed by valid links", which was true only of the armor01 family) also deforms by the link-tree root, and the influence must be KEPT inside blends at its stored relative weight (diranak's reference carries Pelvis 0.0909 / Spine 0.9091 = stored 0.1/1.0 — dropping the unresolved entry from the normalization sum was half the error).ref=25 links end at 'Bip02 Spine', whose INode parent is the linked COM Bip02, but the vertex deforms by 'Bip01 Spine2' = the bone the sub-rig is attached to (the COM is not part of the deformable spline tree). RIGID links (negative form) keep the literal parent node: kitihank/kitinagan's references keep Bip02 itself as a deform bone for their rigid links — an unconditional COM-skip regressed them (measured, reverted to the conditional form same session).Consequence: the COM never enters BonesNames through any stored-link decode path (Bip01/Bip02 appearing in our clod used-bone sets was always OUR fallback, never the reference's), and the applyPhysiqueSkinning 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; zero such verts exist in the clod corpus). Shape corpus re-gate: 141→141 ident tier unchanged at first, then one DIFF file improved (1331 floateq / 735 differ) — no regressions (the armor01 oracle family carries none of these boundary cases, which is why §10z-treize's 100 % bone agreement never saw them).
Render-normal deltas materialize as float Point3 + the (1,0,0) zero-normalize fallback (3 more files, mesh_build.cpp — corpus-wide). clapclap's reference normal on two corners is EXACTLY (1,0,0); the input vert behind them carries only a doubled face pair (the same triangle in both windings — a two-sided fin) sharing smGroup 1, whose contributions must cancel to exactly zero: Max's Point3 operator* materializes each weighted contribution as a float struct before operator+= adds float to float, so fl(w·n) + (−fl(w·n)) == 0 exactly — our unrounded long-double delta left a ~1e-8 residual that normalized into a garbage unit normal. Max's zero-length Normalize() then lands (1,0,0) (corpus-pinned; now replicated). Shape corpus re-gate IMPROVES: 147 byte-identical (+6) + 1330 floateq + 730 differ (−5); veget unchanged (25+97+4). The one sagass_selle vert still off is a chaotic-sign cancellation: three doubled pairs separated in face order (42/101, 43/102, 45/104) make the float sum non-associative, and a 1-ULP face-normal difference decides the residual's sign, which normalizes to ±X — codegen-stable under VS2008/x87 (MSVC long double is 64-bit, and the class persists), so a model tail, not float noise.
Map Extender policy extended to clod (1 file). PR_MO_phytopsy_LOD's "systematic U shift ≈1.019" (§10z-clod residual 4) is a constant (1.01900, 0.00918) UV translation on every vert: the node's stack is Editable Mesh → Map Extender → Physique, the reference was exported with the plugin missing (pass-through → the base map channel), and we apply the recovered cache — the delta IS the Map Extender's stored offset. Per the §10z-quatorze policy the recovered UVs are the correct ones and the reference's UV channel is not a valid oracle: the clod exporter now emits the same MAPEXT <node> tag as the shape exporter, and clod_corpus.py masks UV comparison on tagged nodes (2 corpus files carry the tag; both land FLOATEQ under the mask, everything else still compared).
The remaining 6 (budgeted, --min-floateq 99 --max-diff 6): kitinega ×2 + Kami_Warlord (MRM VB-dedup ±1/±2 verts), vampignon ×2 (multi-smoothing-group corner, mixed groups 0x3/0x43/0x12/0x41 with OR-chains — the lanceroquette class), sagass_selle ×1 (the chaotic-sign vert above). The kitinega split was probed to the bottom and hit the §10i wall — don't re-try these two variants: the mesh carries a partially-duplicated wing patch whose coincident twin verts accumulate near-identical RNormal sums; our two corners land 1 ULP apart while the reference's merged value is a THIRD value 3–5 ULP from both (Max's own closed-source accumulation chain, unreachable by recompilation). Both a double-rounded acos argument and a full-double PC53-emulating cornerAngle were measured — identical clod buckets, no closure, only dedup churn — and reverted (the corpus-validated long-double form stands). All 6 are shared with the shape corpus's known open items; the lever, if ever, is the Max-GT RVertex merge probe (gen_shape_export_dataset.ms channel), not clod-specific work.
VS2008/x87 pass + standing gates. pipeline_max_export_clod builds clean in ~/build_vs2008_wine_pipeline (C++03-safe as written), winebin/pipeline_max_export_clod wrapper added; the x87 sweep reproduces the x64 buckets exactly (99 field-exact + 6 residual, same file list — confirming the residuals are model classes). Both pipeline_max_clod_corpus (budgets tightened 84/21 → 99/6) and the new pipeline_max_clod_corpus_vs2008 are wired in ctest, self-skipping as usual. The shape gate budget was found silently red AGAIN (the full-ctest run this session was the first to actually exercise it since §10z-quinze): the §10z-quinze re-baseline budgeted only the plain-differ count while the gate charges diff + lightmap-diff + mapext-diff — the third instance of the budget-accounting trap, and the standing lesson is now explicit in the CMakeLists comment: re-baseline against the gate's own accounting by RUNNING the gate, not from raw sweep counts. Re-baselined to the measured end state (min-identical 2570 / max-diff 1015, both builds; x64 charges 730+282+3 = 1015, x87 charges 688+282+3 = 973), gate re-run green on both.
Corpus at end of session: clod T1/T2 106/106, T3 105/105 produced, 0 export failures, 99 field-exact (2 mapext-UV-masked) + 6 residual — from §10z-clod's 84 + 21; x87 buckets identical. Shape x64 147 byte-identical + 1330 floateq + 730 differ, x87 161 + 1358 + 688 (both landings net-positive on both codegens; x87 was 158/1354/695 pre-change). Full x64 ctest suite green (13/13 after the budget fix); veget/ig/cmb/zone/skel/anim/swt/regen/prim all pass untouched — the two shared-library changes (physique_skin, mesh_build) only compile into shape/veget/clod, all three re-gated on both builds.
The §9 "separate standalone tool downstream" plan for the lightmapping phase is now pinned to a concrete architecture, decided by Kaetemi. The lightmapper never reads any source format — not .max, not assimp scenes. It is strictly a BUILD step; export writes everything down first. The pieces:
calc_lm/calc_lm_rt ported out of plugin_max/nel_mesh_lib onto pure NeL types. Input is an in-memory scene: receivers as pre-build CMeshBuild/CMeshBaseBuild (calc_lm operates BEFORE CMesh::build — it generates the lightmap UV2 unwrap and re-dedups the VB, so post-build shapes are too late), occluders as world-space triangle sets with the material transparency the shadow rays sample, lights at Max fidelity (type incl. directional + ambient, transform, color/multiplier, shadow flag, light group, animated-light name, hotspot/falloff, attenuation), per-receiver options (the lightmap AppData). The UV2 unwrap + atlas packing is INSIDE the library (part of the algorithm, not any front-end). Output: the lightmapped mesh builds + generated lightmap textures — what calc_lm produced into shape_lightmap_not_optimized at export time.1_export stage. Content split rule: the FILE carries everything calc_lm reads from the scene (nodes, world TMs, embedded mesh builds, occluder geometry, lights, per-node lightmap appdata); the BUILD step's cfg/CLI carries everything the maxscript template substituted from process.py (LumelSize, Oversampling, shadow options, the BuildQuality downgrade) — project config stays project config, never baked into export artifacts. CMeshBuild/CMeshBaseBuild need versioned serial() methods (never serialized before — new engine-side surface, and a long-lived pipeline contract, so version it from day one).pipeline_max_export_shape gains the scene-graph writer (it already evaluates these meshes and reads this appdata). (b) The Max plugin route is updated to the same shape: a scene-graph exporter ported into nel_export + the shape maxscript updated so the in-Max export stops running calc_lm and writes the scene file instead — shape_lightmap_not_optimized production leaves 1_export on BOTH routes. (c) mesh_export (assimp) writes the same file for .blend/.gltf content — what makes the tutorial/authoring route lightmappable.2_build stage: the standalone lightmapper tool reads scene files + project options and emits into the slot the existing lightmap_optimizer already consumes. Tag protocol as usual.Validation per the §9 T3 caveats stands: image-level/aggregate comparison against the reference shape_lightmap_not_optimized set (byte-identity is off the table — the unwrap/packing order won't reproduce Max's; the per-group day/night lightmap layers need their own comparison tier). The 983 lightmap-verified files already confirm our unmapped bases match what calc_lm started from. The corpus pattern generalizes: every lightmapped source produces a scene file, and the lightmapper output is swept against the reference set wholesale (*_corpus.py shape). (2026-07-10 implementation, §11-lm-bis: the "byte-identity off the table" caveat proved too pessimistic for the TEXTURES — the very first lightmap computed came out pixel-for-pixel byte-identical to the Max 2010 reference; what genuinely varies is the atlas PACKING on a minority of files, which gets its own comparison tier.)
The §11-lm architecture is implemented end-to-end for the headless route (producers (b) Max plugin and (c) assimp remain). The pieces, as landed:
Engine (nel3d). CMeshBuild/CMeshBaseBuild gained their versioned serial() (never serialized in 25 years; sub-records CVertLink/CInterface/CInterfaceLink gained theirs, CCorner::serial's nlassert(0) // not used removed). nel/3d/lightmap_scene.h defines the intermediate: CLightmapScene = ProjectName + CLightmapLight[] (the SLightBuild port: type incl. directional/ambient, position/direction, attenuation radii, hotspot/falloff half-angle radians, ambient/diffuse/specular, cast-shadow, multiplier, projector bitmap+matrix, per-light exclusion name set, soft-shadow appdata) + CLightmapSceneMesh[] occluders (name + object-space mesh/base builds) + CLightmapReceiver[] (name, base build, per-geom CLightmapReceiverGeom — pre-build CMeshBuild, multilod slot config, per-slot MRM params, per-node lightmap appdata (LumelSizeMul, LMC enable + ambient/diffuse ×3 groups), FirstMaterial, per-geom RT exclusion name set — plus the shape-build recipe: multilod/StaticLod/WantMrm+params, pre-remap material names, animated-materials/auto-anim flags, DistMax, coarse routing). Every record has its own serialVersion. nel/3d/mesh_lightmapper.{h,cpp} + _plane/_rt are the faithful calc_lm/calc_lm_plane/calc_lm_rt port (same file split, algorithmic code verbatim — SGradient double-precision gradients, SLMPlane mask/packing, CRTWorld six-face quadgrid accelerators, transparency sampling through the occluder materials' texture alpha): the Max scene walks became CLightmapScene iteration, per-node appdata became receiver-geom fields, _Ip->GetCurFileName() became ProjectName, and light positions get the -vGlobalPos translation the original baked into convertFromMaxLight. Same in-engine placement precedent as CZoneLighter/CInstanceLighter.
Producer (a): pipeline_max_export_shape --lm-scene <dir> writes <maxstem>.lmscene per source with lightmapped receivers; the unmapped shape output is unchanged. New decodes, corpus-pinned: light intensity = old-pblock param 1 (1.5 on the animated-street braseros; omni layout 0=color, 1=intensity, 2=contrast, 3=soften-diffuse, 4/5 near-atten, 6/7 far-atten — consistent with the ig decode's spot 4/5 hotspot + 9/10 atten); light cast-shadows = word chunk 0x2570 (PROVISIONAL — the only word that varies per light like the artist shadow toggle: sun=1, accent omnis=0; validated in aggregate by the TGA sweep); the light EXCLUSION LIST = light-object chunk 0x2800 = { 0x03e9 uint32 count, 0x03ea count × uint32 NODE HANDLES, 0x03ec uint32 flags (6 = exclude illumination+shadow, the only value observed) }, with handles resolving through node chunk 0x0a32 = the persistent Max node handle — decoded off zo_cn_mairie's zo_mairie_omniamb/omniamb01 pair against the reference lightmap logs (omniamb excludes exactly zo_mairie_rdc and the reference rdc layer lacks exactly that light; omniamb01's 37-entry list covers the couloir/sous-sol receivers; the glow-bulb pattern — Omni31s..36s excluding their own zo_flare/globe meshes — is everywhere). The reference's convertFromMaxLight inserts every ExclList entry as an exclusion NAME regardless of the include/exclude flag bits — reproduced as-is. (The first-pass "exclusion lists are unused corpus-wide" conclusion from the light-chunk vocabulary scan was WRONG — the scan had blind-listed 0x2800 as known without examining its content; wiring the decode collapsed the whole 258-file log-diff class of the first full sweep.) Projector bitmaps remain unused corpus-wide (no projector storage found on any light). Lights enumerate in NODE-TREE pre-order (getLightNodeList recursion — the §10w tree-vs-storage lesson; the enumeration order decides per-layer light order and layer creation order). Occluders replicate the reference selection: root-level cast-shadow geometry (the shape maxscript's node.parent == undefined && isCastShadow selection with bExcludeNonSelected=true), addNode's non-zone/non-accelerator filters at export, the bCastShadows/light-interaction filters in the lightmapper. Per-geom RT exclusions carry the addChildLodNode/addParentLodNode set as resolved node names. XRef'd lights (the ascenseur pattern — light nodes whose object is an XRef into another .max) resolve through the existing scene_lib XRef machinery.
Consumer: nel/tools/3d/shape_lightmapper (pure NeL, no pipeline_max dependency — links only NeL::misc/3d). Reads .lmscene + build options (--lumel-size, --oversampling, --no-shadow, --lighting-limit, --lightmap-log, --lightmaps <dir>, --coarse-out, --texture-path for occluder transparency textures), runs CMeshLightmapper::calculateLM per receiver geom, finishes the shape build from the recipe (CMesh / CMeshMRM paths; multi-lod receivers are collected but deferred — the original's per-slot base-material flow needs its own validated round), applies animated materials/auto-anim/DistMax, and writes the lightmapped .shape (same export-era version patches + COFile-temp discipline as the exporter) + the packed lightmap TGAs + the light-list log.
Two reference-exporter behaviors reproduced deliberately:
calculateLM builds the texture name as (const char*)projectName + "_" + node + "_N.tga" where projectName is a UTF-16 (ucchar) buffer from _wsplitpath — reinterpreted as char* it terminates after the first character's low byte. Every reference lightmap texture carries a single-letter prefix (m_ma_acc_ascenseur_0.tga from ma_acc_ascenseur.max), while the lightmap LOG file name (through a correct ucstring::toUtf8) carries the full stem (ma_acc_ascenseur_ma_acc_ascenseur.txt). Both visible in the reference data; the port reproduces the truncation for byte-compatibility.calculateLM returning false (no lightmapped faces after the material filter, or no valid UV2 mapping) does not abort the shape build — the reference flow continues into the ordinary build (the ascenseur doors class: LightMap materials, no lightmap in the reference either).First-file validation (ma_acc_ascenseur.max): the computed lightmap TGA is pixel-for-pixel byte-identical to the Max 2010 reference (16384/16384 channel samples); the light-list log matches the reference exactly including order (15 lights); the lightmapped .shape is FLOATEQ (same 5807 bytes, residual = 1-ULP float noise in normals + lightmap UVs). The atlas packing itself reproduced Max's.
Corpus harness: lightmap_corpus.py (pipeline_max_corpus_test/). Per shape-source .max: export with --lm-scene, lightmap, then three comparison tiers per receiver: (1) lightmapped shape vs the shape_not_optimized/shape_with_coarse_mesh reference via --compare (identical / floateq / shape-repacked — DIFF confined to VB value 3, the lightmap UV set / diff); (2) TGA vs shape_lightmap_not_optimized pixel-wise (tga-identical / tga-near / tga-repacked — pixelwise large but sorted-channel QUANTILE distance ≤ tolerance, i.e. the same lightmap content packed at different atlas positions (packing ties break differently under 1-ULP input noise; plane placement cascades) / tga-diff) + missing/extra accounting; (3) light-list log vs the reference .txt (ordered (name, group, animation) per layer). Per-file scratch dirs (the veget -j race lesson).
Corpus numbers: first full sweep (pre-exclusion-decode): 108 scenes / 1012 single-mesh receivers — shapes 7 identical + 269 floateq + 84 repacked + 652 diff; TGAs 366 identical + 85 near + 578 repacked + 992 diff; logs 612 match + 258 diff. Wiring the exclusion-list decode collapsed the log-diff class (zorai: 3 → 0 on the spot; the "mairie lantern" mystery of the first triage — lights we included that no reference log ever listed — was exactly this). The harness then gained per-project build options read from the workspace process.py (ecosystems/lacustre runs Oversampling = 8, everything else 1 — passing it moved the lacustre village families to identical/near). Landing sweep (gated in ctest as pipeline_max_lightmap_corpus): shapes 7 identical + 309 floateq + 93 repacked + 603 diff; TGAs 445 byte-identical + 97 near + 615 repacked + 865 diff (median diff quantile 2.75 — a long near-tolerance tail; only 26 files exceed quantile 20); logs 837 match + 33 diff (desert 15 + lacustre 18, incl. an extra-layer class); 1 harness-error = fy_cn_forge_clusterized's biggest receiver exceeding the 3600 s per-scene timeout (compute-bound O(n²) face grouping — the same cost that made the in-Max exports overnight jobs). The existing pipeline_max_shape_corpus gate re-verified green after the writer landed (the exporter's default path is untouched). The standing diff classes:
z_tr_bar_loupiote_inter exemplar), the small-quantile TGA tail (1–5 gray levels — candidates: the provisional 0x2570 shadow decode on specific lights, transparency-texture resolution differences, x87-vs-SSE ray-boundary noise), and the forge timeout (needs either patience or an interior-scale optimization of the O(n²) grouping).Producers (b) and (c) — the in-Max plugin route (scene-graph exporter in nel_export, maxscript stops running calc_lm) and assimp mesh_export — remain open; the format and library they write to/feed are now proven by the headless route.
#define. No batch of typing lands on a red corpus.parse/clean/build/disown/init/toStringLocal + createChunkById), call the parent first (parse/build) or last (disown), guard on m_ChunksOwnsPointers, use getChunk/putChunk/m_ArchivedChunks exactly as §5 describes, and put chunks back in the order they were taken. CEditableMesh is the template for "typed class that mostly passes through"; CNodeImpl for "small fully-typed class"; CAppData for "container with its own entry bookkeeping"; CDllDirectory/CClassDirectory3 for "entry table with position cache" — start from the nearest template, don't invent a fifth shape.PMBS_GEOM_BUFFERS_PARSE and re-gate; (e) claim the noted tri/poly object ids (0x0901-0x0904, 0x0906-0x090c); (f) PARSE_NELDATA: typed AppData decoders per export_appdata.h; (g) materials/param blocks (fill the stubs, register them) as export needs them; (h) node transform controllers (currently unknowns — needed for object placement at export); then the nel_mesh_lib export logic retargeted onto this object model, T3-gated. The lightmapping phase is explicitly out of the exporter: shapes export unmapped, and calc_lm becomes a separate standalone tool validated in aggregate (see T3 caveats).