pipeline_max_design - ryzom/ryzomcore GitHub Wiki
title: Pipeline Max β Design and Coherency Contract description: Codified design of the headless .max parser/writer (nel/tools/3d/pipeline_max) β the storage model, the parse/build/disown lifecycle, roundtrip invariants, known defects, and the rules any extension work must follow published: true date: 2026-07-10T00:00:00.000Z tags: editor: markdown dateCreated: 2026-07-05T00:00:00.000Z
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:
- Whole-library coherency β every class participates in one shared storage lifecycle with one ownership discipline; a new class that improvises its own variant breaks the guarantees for everything beneath it.
- Roundtrip coherency β a file that is read and written back must be byte-identical per stream, whether or not any of it was parsed into typed form, except where a normalization is explicitly documented and whitelisted. Unknown data is never guessed at, never dropped, never reordered.
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.
-
Reads and writes 3ds Max
.maxfiles 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 byCStorageOleIn/CStorageOleOut;CStorageStream(storage_stream.h/.cpp) is a plain in-memoryNLMISC::IStreamover those bytes (seek/read for parsing, growable back-patchable write buffer for serialization). - Target versions: Max 3 through Max 2010.
typedefs.hdefines the version constants (Version3= 0x2004 β¦Version2010= 0x2012). The corpus inryzomcore_graphicsspans this whole range; the format is essentially stable across them, with per-version variations inside individual classes (e.g.CSceneImplgains a 12th reference after Max 3). - Compressed
.maxfiles 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 viaCStorageOleIn::openreturning false.) -
storage_file.*is an empty stub (constructor/destructor only). Placeholder, no design in it. (The 2012derived_object.*/wsm_derived_object.*root-level stubs graduated into the typedbuiltin/derived_object.*/builtin/wsm_derived_object.*scene classes β Β§10j-six.)
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):
-
Reader β accepts both v3 (512-byte sectors) and v4 (4096-byte sectors); the whole modern corpus is v4. Parses the header, the DIFAT β FAT chain, the directory (walking the root storage's red-black child tree in-order to enumerate root-level streams), the mini-FAT and mini-stream. A stream
< MiniStreamCutoff(4096) is read from the mini-stream via the mini-FAT, otherwise from the big-sector FAT chain; sectorsis at file offsetSectorSizeΒ·(s+1). Chain-walks are bounded to guard against malformed files. -
Writer β emits v4 with a fixed, deterministic sector layout: big-stream data, then the mini-stream container, mini-FAT, directory, FAT, and DIFAT (the last only when > 109 FAT sectors are needed β rare at 4096-byte sectors). The directory is a name-sorted balanced tree (CFB key order: shorter names first, then case-insensitive by UTF-16 code unit); the root-storage CLSID carries the .max class id. Streams
< 4096bytes 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):
-
Native reader β‘ libgsf reader, per stream: byte-identical class id, stream set, and per-stream content across broad corpus samples (a 300-file general spread + 241 files covering ligo landscape, the smallest files, and the largest β including a 146 MB
zonematerialβ with 0 mismatches). -
Native writer round-trip: 300 files read β written by the native writer β re-read by both the native reader and libgsf, every stream + class id byte-identical (exercises the mini-stream path via the 70-byte
\05DocumentSummaryInformation, big streams, and the class id; libgsf reading our output confirms the file is spec-valid). -
End-to-end: the corpus tester's T1 (structural roundtrip) and T2 (parseβbuild roundtrip) pass 500/500 on a native build,
--modify-save-test(which exercises the writer through the whole scene-graph edit β whole-.maxwrite β reload path) clean, and thepipeline_max_export_skel/_export_zone --ligooutputs 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):
-
Empty-list dereference.
CSceneClass::getChunkdidm_OrphanedChunks.begin()->firstwith no empty check. Dereferencingbegin()of an emptystd::listis UB; glibc reads a harmless sentinel and falls through, but MSVC's checked iterators (_SECURE_SCL=1, on by default even in VS2008 release) raise0xC0000417and abort. Any class parsing a chunk after it has drained its orphans hit it. Guarded with!empty(). -
Static-initialization-order fiasco. The class table defines each
SuperClassIdby chaining to its parent's βconst TSClassId CRklPatchObject::SuperClassId = BUILTIN::CPatchObject::SuperClassId;β across translation units. Because the parent isextern, 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::SuperClassIdcame out0x0, 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 nameGeomObjectUnknown. 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):
-
Time-slider range / current time β Config stream, container
0x20b0(the time configuration): sub-chunk0x0010= 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 exactly0x0060β1975200 (=12345Γ160) and0x0070β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.animexport (ranges come from key spans, Β§10c) but part of an authored deliverable's polish β--author-jumppatches 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). -
Thumbnail β
\x05SummaryInformationstream, 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 whoseMETA_STRETCHDIB(0x0F43) record carries a plainBITMAPINFOHEADERDIB (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.
storage_chunks.h/.cpp. Max chunk format:
- 6-byte header:
uint16 id,uint32 size.sizeincludes the header; bit 31 = "container" flag (chunk contains child chunks rather than leaf data). - 64-bit extension:
size == 0means auint64follows (14-byte header total), same bit-63 container flag. The reader accepts these and downgrades to 32-bit bookkeeping (throwsEStreamif β₯ 2 GiB). The writer round-trips the header width per chunk (2026-07-05, see Β§11 "Fixed"): eachIStorageObjectrecords whether its own chunk was read with a 64-bit header (tri-state β chunks freshly authored by typedbuild()have no original width and fall back to their containing container's aggregatem_Was64Bit), andenterChunk(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. - Write path:
enterChunk(id, container)writes the id and a0xFFFFFFFFsize placeholder;leaveChunk()seeks back and patches the real size with the container bit. Sizes are therefore never computed fromgetSize()β see the defect list (Β§11) before ever relying ongetSize(). - Read path:
leaveChunk()returns the number of bytes skipped (unconsumed) in the chunk.CStorageContainer::serialthrowsEStorageif 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, extendsNLMISC::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 orderedstd::listof(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,setSizeprimes it, then it serials itself. Writing: iterate the list in order, wrap each in a chunk. -
createChunkByIdis 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) isCStorageContainerfor containers andCStorageRawfor leaves.CStorageRawis 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. - Typed leaves:
CStorageValue<T>(single value;std::string/ucstringspecializations 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). - A typed leaf must serialize to exactly the same bytes it consumed.
CStorageValue<ucstring>reads/writes raw UTF-16 (size/2chars);CConfigScriptMetaStringshows the pattern for a length-prefixed, null-terminated string. When a format has optional fields, the bitfield discipline inCGeomPolyFaceInfo::serial(geom_buffers.cpp:155) is the model: parse each known bit, clear it, andnlerroron 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:
-
After
serial(in):m_Chunksowns everything; the object is pure storage.true. -
parse(CSceneClass): parent'sparseruns first; then all chunks are moved ontom_OrphanedChunksand the flag flips tofalse. Subclasses then claim their chunks withgetChunk(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 tom_ArchivedChunks(deleted onclean, forgotten ondisown). Chunks the class never claims stay orphaned and are re-emitted verbatim bybuildβ that's per-chunk forward compatibility inside otherwise-typed classes (seeCEditableMesh::parse, which drains a dozen known-unknown ids in their observed sequence purely to keep them in position). -
Failure discipline: if a class cannot make sense of its data, it calls
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 byif (!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). Afterclean, the original bytes are gone βbuildmust regenerate them bit-perfectly from typed state. A class whosebuildcannot yet do that must notclean(compareCAppData, which cleans its raw entry values becauseCAppDataEntry::buildre-serializes them, withCDllEntry::clean, which deliberately does nothing becausem_Chunksretains ownership of the two value chunks it aliases). -
build: CSceneClass re-inserts the remaining orphans intom_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.putChunkcallsbuildon any container passed through it. TheputChunkValue<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 totrue, recurses. Afterdisown, the object is again pure storage and can be serialized out.CSceneClass::disownsanity-checks thatbuildactually 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. - Special ids
0x2032("OSM Derived", ClassId (0x29263a68, 0x405f22f5)) and0x2033("WSM Derived", (0x4ec13906, 0x5578130e)) β derived-object wrappers that hold modifier stacks β are hardcoded inCSceneClassContainer::createChunkByIdrather than directory lookups: they resolve through the registry's exact class entries under superclass 0x0 (the file stores no superclass for them; CBuiltin registers the typedCDerivedObject/CWSMDerivedObjectthere β Β§10j-six), falling back to the historical reference-target-typed unknown when a registry doesn't carry them. -
CSceneClassRegistrymaps(SuperClassId, ClassId)βISceneClassDesc, andSuperClassIdβISuperClassDesc. Resolution order increateChunkById: (1) exact registered class; (2)createUnknownon the registered superclass β this instantiatesCSceneClassUnknown<TSuperClass>, which inherits the typed superclass, so e.g. an unimplemented material still runsCReferenceMaker::parseand gets its reference wiring parsed and rebuilt while its own chunks stay orphaned/verbatim; (3) an invalidCSceneClassUnknown<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 ~40CSuperClassDescUnknown<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::registerClassesadds EditableMesh;CEPoly::registerClassesadds EditablePoly;BIPED::CBiped::registerClassesadds BipedDriven. A new typed class = write the class + add oneregistry->addline; nothing else changes. Plugin-DLL grouping is mirrored in namespaces (BUILTIN,UPDATE1,EPOLY,BIPED) with aIDllPluginDescper DLL (update1.dlo,EPoly.dlo,biped.dlc); follow that pattern for new plugin classes (e.g.nelexport.dluwould get its own namespace if it ever needs scene classes β though NeL data itself doesn't; see Β§8).CBipedDrivenoriginally landed underBUILTINβ 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 theIDllPluginDesc::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 am_ChunkCachethat remembers where the entry run sat among any non-entry chunks, sobuildre-emits entries at the original position. Both throwEStorageParseif entries are interleaved with other chunks (never observed). Internal pseudo-indices: dll index β1 = builtin, β2 = maxscript script.getOrCreateIndexappends new entries when writing new classes into a file.reset()exists to blank the directory for from-scratch writes. -
Storage-index mapping is currently read-only:
CSceneClassContainer::parseassigns indices by position,build'sgetOrCreateStorageIndexjust 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; overlay: 0x0963
β β flags/hidden, 0x099c render flags, 0x096a/b/c object-offset PRS β Β§10j-ter)
β ββ CRootNode β
ββ CTrackViewNode β (0x0110/0x0120/0x0130 per child, empties 0x0140/0x0150)
ββ CDerivedObject β (OSM Derived, fixed chunk id 0x2032; modifier-slot/ModApp overlay β Β§10j-six)
β ββ CWSMDerivedObject β (WSM Derived, fixed chunk id 0x2033; shared implementation)
ββ CBaseObject β (no own chunks)
β ββ CObject (pass-through)
β ββ CShapeObject β (superclass 0x40; BezierShape/Spline3D overlay β Β§10j-quater)
β ββ 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)
ββ CParamBlock β (old-style param block, superclass 0x8; Β§10j-bis)
ββ CControlFloatLinear β (0x2001; Β§10b/Β§10k) + the 9 other typed keyframers (Β§10b)
β CModifier, CMtl, CStdMat, CStdMat2, CTexmap, CBitmapTex β 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)
-
References (
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_ReferenceMaprecords which form was parsed sobuildre-emits the same form; the leading 0x2035 value is preserved. Indices resolve throughcontainer()->getByStorageIndex, live pointers areCRefPtr(non-owning, self-nulling β the scene container owns objects). Subclasses overridegetReference/setReference/nbReferencesto give slots semantic names (CSceneImpl's 12 slots;CNodeImpldoes not β node parent/name live in node chunks, and slot 1 of a node is its object, perINode::dumpNodes). -
Version-dependent shape:
CSceneImpl::parseasserts 11 references, 12 (m_TrackSetList) only whenversion > Version3. This is the template for all version forks: assert what the version implies, don't silently accept both. -
Nodes: parent linkage is stored childβparent (0x0960: parent storage index + flags);
setParentmaintains the in-memory child sets.INode::findresolves by case-insensitive user name β that is the instance-name hook the TOML sidecar route will use. -
Geometry (
STORAGE::CGeomBuffers, chunk 0x08fe): typed serializers exist for the tri buffers (0x0914 vertices / 0x0912CGeomTriIndexInfofaces 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_PARSEis 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, assertingtriangles == cuts + 1;pipeline_max_dump::exportObjalready produces valid.objoutput 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). -
Two-pass parse via filters:
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 asCTriObject::parse(version, PMB_TRI_OBJECT_PARSE_FILTER)βCGeomObject::parse(version, PMB_GEOM_OBJECT_PARSE_FILTER). The filter constants are magic per-class tokens (0x432a4da6etc.), not flags. Separately,TParseLevel(PARSE_INTERNAL,PARSE_BUILTIN, and the commented-outPARSE_NELDATA,PARSE_NEL3Din 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.objexport 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 andnlerroraborts. -
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) viaNLMISC::COFileand reads it back β going throughCMemStreamdoesn't work because itslength()returns the current write position andCStorageChunks::leaveChunkneeds 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. Driverskel_corpus.pyenumerates the corpus from the workspacedirectories.pyfiles, classifies biped vs non-biped by scanning for theBiped ObjectUTF-16 marker, skips git-lfs stubs (files whose first 8 bytes aren't the OLE magic), and reports per-bucket totals β invokepython3 skel_corpus.py --allfor the full T1+T2+T3 sweep. Wired intoctestaspipeline_max_skel_corpus(pipeline_max_corpus_test/CMakeLists.txt): self-skips (exit 77,SKIP_RETURN_CODE) when the privateryzomcore_graphics/ryzomcore_leveldesignasset 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,overrideFFfor 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. -
Codified test protocol for the extension work (the tutorial-roadmap side-quest's "exhaustive unit test suite"):
-
T1 β structural roundtrip, no parse. Every stream of every corpus file:
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_testover 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. -
T2 β parse/build roundtrip.
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 enforcesclean()between parse and build (CDllDirectory::build/CSceneClass::buildassertm_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.maxfirst-person-hand files) β see Β§11. -
T3 β export snapshot. Once export logic exists: pipeline output diffed against the prebuilt Ryzom Core data set (
~/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 underfauna_skeletonstoo (previously onlycharacters_skeletonswas 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/_smallvariants 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):-
Flare sizing flag (
_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::serialforces the flagfalsefor 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. -
Non-standard UVW mapping modifier. Some assets use a custom UVW-mapping extension written by a Ubisoft employee (a non-standard plugin modifier β it announces itself in the file's DllDirectory, so detection is trivial and unambiguous). The reference
.shapeoutputs 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. -
Lightmapped scenes. The headless pipeline exports these unmapped; the lightmapping phase (currently
calc_lm*insidenel_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.
-
Flare sizing flag (
- Bucket all results per file version (Max 3 β¦ 2010) β regressions cluster by version.
-
toStringdumps are diagnostics for triage; never assert on them.
-
T1 β structural roundtrip, no parse. Every stream of every corpus file:
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.
-
Structure: four phases per process β
0_setup.py/1_export.py/2_build.py/3_install.pyβ underprocesses/<name>/. Eleven processes are Max-driven (have amaxscript/directory):shape,anim,ig,ligo,zone,skel,swt,veget,clodbank,pacs_prim,rbank. -
Workspace config:
workspace/projects.pylists projects (common/objects,common/sfx,common/characters, per-continent, per-ecosystem, β¦). Each project directory holdsdirectories.py(source directories inside the graphics database, export/build directory names, tag directories, lookup paths) andprocess.py(which processes run, plus per-project export options β e.g.ShapeExportOptExportLighting,ShapeExportOptShadow,ShapeExportOptLumelSize,ShapeExportOptOversampling;BuildQuality == 0globally downgrades these for fast builds). The headless exporter must consume these same options with these same names. -
Max invocation (see
processes/shape/1_export.py): the Python driver template-substitutes%TOKENS%(source dir, output dirs, options, log path) intomaxscript/shape_export.ms, installs it into the Max user scripts directory, and runs3dsmax.exe -U MAXScript shape_export.ms -q -mi -mipβ Max then iterates every.maxin the source directory itself. Incrementality and crash-resilience live in the tag protocol: a.max.tagper successfully exported source file (compared byneedUpdateDirByTagLog), amax_running.tagsentinel 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. -
Per-node export policy is in the maxscript, driven by AppData:
shape_export.ms::isToBeExportedskips nodes withNEL3D_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 export outputs (per project):
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 bylightmap_optimizerin2_build.py. Under the unmapped-export design (Β§9 T3 caveats), the export stage stops producingshape_lightmap_not_optimizedcontent and the standalone lightmapper takes over that slot in the flow, feeding the same2_buildoptimizer. -
Precedent for a non-Max route already exists in-tree:
processes/shape/1_export.pyfirst runsmesh_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.maxexporter should be wired in exactly the same shape as that branch β andmesh_exportis 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:
- Rotation source: base of spine, base of neck, upper arms, upper legs, feet β COM. Clavicle β last spine link. All others β their INode parent.
- Position source: base of spine β COM. Clavicle β last spine link. Upper leg β pelvis. All others β their INode parent.
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:
- Walk the INode hierarchy for name/order (as today).
- For every biped bone (has a
BipDriven Controlas its TM controller), read chunk 0x0200 to identify(bone_id, link_index). - Look up the local transform for that
(id, link)in the rootBiped(0x9155) controller's per-bone table β the transforms Autodesk stores are whatINode::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). -
UnheritScaleon the .skel bone must be set totruefor biped bones whose INode parent is also a biped bone (fromgetNELUnHeritFatherScaleinplugin_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:
-
Coordinate frame. The biped's internal representation is Y-up (Max/NeL world is Z-up). Position conversion
(x,y,z) β (x,-z,y). Rotation basis changeC = [[1,0,0],[0,0,-1],[0,1,0]]; a stored Max-row-vector 3Γ3Mbecomes the NeL local rotationR_nel = C Β· Mα΅(columnsI=(m0,-m2,m1) J=(m4,-m6,m5) K=(m8,-m10,m9)), thenCMatrix::getRot(). Verified bit-exact on the finger matrices. -
Aim==exact. Each bone's world local-X axis points exactly at its child (
IΒ·(child_wpβself_wp)/|β¦| = +1.000for 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 referencedrot β (0,0,0,Β±1)β and only direction-changing joints carry real rotation. This is why "read the joints, inherit the rest" works. -
Base frame. Bip01 (COM) world = chunk 0x0104 (a Y-up 4Γ4; its translation converted gives Bip01's world position β
(0,0,height)). Bip01's world rotation is the canonicalCQuat(0,0,-βΒ½,βΒ½)(β90Β° about Z), constant across the corpus β note this differs from the.skelDefaultRotQuat, which is identity for the root (reset) and uses thedecompMatrixconvention, not thegetRot()/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), sopelvisWorld = Bip01World Β· (0.5,0.5,0.5,-0.5). - Direction-changing joint rotations are stored as quaternions at fixed float offsets in per-limb record chunks. (SUPERSEDED 2026-07-06 β see "Differential-dataset decode round" below: the offsets were validated but the perm/sign forms for Foot and UpperArm were only approximately right, the halves are right-side-first, and each side reads its own half. The original table is kept for history:)
| 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):
-
Max rotation-controller values are stored in the inverse convention relative to the node-TM rotation.
getLocalTransformnow conjugates the TCB quaternion (0x2504). The reference exporter never hit this because it readGetNodeTM()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 bothdposanddrot(byte-match 81.9% β 90.9%; the rest is InvBindPos float noise), andprs:otherwent from 422/508 era-matched bones wrong to 7. -
PRS children of a biped COM node don't inherit the COM's rotation: their stored rotation value is world-frame (position stays parent-relative). Verified bit-exact on every corpus
nametag marker including tilted-COM rigs (tr_mo_c03), and it makes Footsteps'drotexact 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:
-
Half order is RIGHT-first. All paired records (pose records 0x0069/0x006a, and the paired sections of 0x000f/0x0010) store the right side in the first half, left in the second (R Hand lands at
0x006a[28..31], L Hand at[half+28..]; R Clavicle at0x0010[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. -
Foot (solves the free-foot problem):
[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. -
Hand/wrist (was "not stored"): the default hand frame is the forearm twisted
β(Ο/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). -
UpperArm (old perm was ~4Β° off even at dataset baseline): exact per-side rule
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). -
Clavicle 2-DOF complete: relative to the LAST SPINE LINK (not the neck):
rel = Rx(βa) Β· Rz(βΞ΅) Β· B(b)witha = 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. -
Finger/toe pose blocks: inside each 0x0069/0x006a half, 10-float blocks from in-half offset 46:
[46+10k..49+10k]= chain k's base-link delta quat (local post-multiplyqmat(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 rotationRz(angle)(this replaces "straight-chain" finger/toe links β the humanoid default curl 0.2333 rad was always there). -
Leg-record head shift: the 0x0069 half's head grows 2 floats per leg link beyond 3 β 4-link rigs (mounts/birds) put the thigh quat at
[4..7], foot at[30..33], toe blocks at[48+10k]. Knee[0]and horse-ankle[1]do NOT shift. -
Neck record 0x0065 is authoritative:
[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 at0x0064[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. -
Footsteps Z is derived, not stored: the Footsteps marker's world height equals the (left) toe attach height (exact across the dataset's ankle/height/foot-scale variants) β patched post-walk. Figure-mode footsteps rotations don't persist at all (nothing stored, node snaps back). Pelvis figure rotation (
0x0066[0..3], a quat) is stored but also not applied to the pose; identity on all 169 corpus files. -
Head quat
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:
-
dpos residues (err 0.0262): the reference-era mismatch class (
_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'ssc_*scale variants localize candidate slots: leg scale at0x000f[31]-region +0x01f9, per-chain length tables at0x01f4..0x01fd). - L-half base matrices for fingers/toes on asymmetric rigs (corpus-era encoding unknown β see the era-divergence warning; 13 L finger bases still off). Fresh-format side conventions are now settled (Β§10e: toe rotation sides flipped, positions not, fingers legacy-compatible) β the corpus-era L-half encoding remains open.
-
prs:namemarkers 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:
-
Node selection: the anim maxscript selects
$Bip01plus nodes withNEL3D_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$Bip01is 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. -
Key conversions (mirroring
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 MaxInverse(srtm)*stm*srtmdiagonal, which in column convention issrtmΒ·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. -
Stale-cache TCB keys: key flag 0x10 marks the stored relative angle-axis as invalid (garbage axis + zero angle observed on single-key tracks in
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). -
Validation status: 10/10 direct-reference files byte-identical (4 sky domes vs
core4_data/sky, eolienne + borne_teleport + 4 karavan kites vs~/characters/anim_export); 61/61 fauna (plante_carnivore) via in-treeanim_builder+ fauna cfg vscore4_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 4ge_mission_kite_kamique_*files carry real bipeds and are deferred to the biped anim phase (exporter refuses with exit 2). -
Harness:
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 aspipeline_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):
- COM rot =
CΒ·conj(turn)Β·Cβ»ΒΉ; COM pos =(h.x, βh.zβ, vertical.z). - pelvis world =
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 asC_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). - upper arm =
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). - finger base local =
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 absoluteRz(angle), angles unwrapped mod 2Ο against the previous key (stored values wrap: 0 β 6.22 β 0 across keys). - Positions: spine/tail base attach rigid in the COM frame, pelvis offset rigid in the pelvis frame, clavicle offset rigid in the last-spine-link frame β all folded from the figure walk's world transforms; everything else parent-relative with figure-static local offsets.
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:
-
Note tracks (chunk 0x2140 on the Animatable). Max stores note tracks in the 0x2140 chunk
CAnimatablehas always preserved verbatim (now exposed read-only viaCAnimatable::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. -
SkeletonSpawnScript (36 craft anims). Nodes flagged
NEL3D_APPDATA_EXPORT_SSS_TRACKcontribute their note-track scripts (lspawn/wspawn/lkill/wkill <shape>lines); the CSSSBuild port compiles them into thespawn_scriptConstString state-track plus theCAnimation::addSSSShapeset exactly like the reference (shape lowercased +.shapeappended, 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. -
Morpher (7 emo anims). The Morpher modifier (Morpher.dlm, ClassId (0x17bb6854, 0xa5cba2a3)) on the node's OSM Derived object: references 1..100 = per-channel ParamBlocks (ref 0 = the modifier's own), references 101+i = channel i's target node. The channel's factor controller is its ParamBlock's reference 0 (Bezier Float, percent values). Track name =
<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). -
Cameras (6 first-person anims). The LookAt TM controller (0x2006): reference 0 = the target NODE, 1 = position, 2 = roll (Bezier Float), 3 = scale; GetRotationController is NULL (no rotquat track for LookAt nodes). Roll (
roll) and the target node's position controller (target) export only for camera nodes (object superclass 0x20), replicating isCamera. - NeL particle systems (3 gun-shot anims). The scripted "Particle Sys" plugin object (ClassId PartA 0x58ce2893 β scripted-plugin PartB varies per script edit, match PartA only; definition in plugin_max/scripts/startup/nel_ps.ms): ParamBlock2 params 0=ps_file_name, 1..4=PSParam0..3 (float), 5=PSTrigger (bool). The PB2's 0x000e param records are [u16 param-id][u16 type][10 bytes][flag byte(+payload)]: flag bit 0x40 = constant value follows (no controller); records WITHOUT it are controller-backed and own the PB2's reference slots in record order (single-controller case validated; no multi-controller PS exists in the corpus). PSTrigger's controller is the On/Off bool controller (PartA 0x984b8d27): chunks 0x0130 = key count, per key 0x0100 = time + 0x0110 = flags, 0x0140 = boundary state; the value toggles at each key with 0x0140 read as the state before the first key (the corpus only carries even key counts, which cannot discriminate before/after β an odd-count file would). β ConstBool track. Whitelisted normalization: the reference's 0-key On/Off track serializes uninitialized stack floats as its range (2004-era exporter bug); ours writes [0,0].
- TCB Scale typed controller (30 weapon-box scale tracks). See Β§10b β ClassId 0x442315, key chunk 0x2523.
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:
- TCB time-record slot order is (easeTo, easeFrom, tension, BIAS, CONTINUITY) at [5..9] β continuity and bias were SWAPPED in the parser (pinned by a_tcb_cont0/a_tcb_bias0; undetectable while keys sit at the 25 defaults, but 50/60 sampled corpus biped anims carry non-default TCB β a real corpus-wide correctness fix). Ease (slots [5]/[6], UI 0..50 β /50) is now applied as the classic Max per-segment time warp (a = start key's easeFrom, b = end key's easeTo); the measured warp deviates slightly from the classic shape (linear slope 1.2222 for UI 25 vs classic 1.3333; smooth tail) β residual β€ 0.018 on the synthetic sweep, only 1/60 sampled corpus files use ease at all, left approximate.
- World-hold rule for unkeyed roles (a_fk_spine: keyed spine, everything else unkeyed β GT keeps arms/legs/head/tail/feet EXACTLY at figure pose while the spine chain rotates): unkeyed world/COM-frame channels (pelvis, spine base, tail base, head, upperarm, foot) hold their FIGURE orientation COM-anchored β they do NOT follow the node hierarchy; parent-relative roles (chain links, neck, hinges, clavicle, hand, finger/toe) do. Thigh holds pelvis-anchored, and the thigh POSITION always anchors to the pelvis frame (same hierarchy-vs-kinematics split as the Β§10e figure fix β fresh rigs hang thighs off the lowest spine link).
- Toe base delta quats interpolate with per-segment cosine ease + slerp β angle = key-delta Γ sinΒ²(Οu/2), zero slope through every key, TCB params ignored (a_fk_toe: exact at every quarter frame). Finger base deltas stay TCB squad (a_fk_finger discriminates β CS genuinely treats the two differently). Toe base composition also needed the Β§10e fresh-format side flip (right = direct, left = LR mirror of the composed local, era-gated).
- IK: the stored channels already carry the solved values in every steady state β planted, free, pivot-roll, all three ankle-tension settings, overextension, and hand pins all sit at the Ξ΅-floor with NO solver. Only blend-transition intervals (alpha ramping 0β1, a_ik_blendtrans) deviate: thigh/calf ~3 cm/0.034 during the ramp (the experimental env-gated 2-bone solver regresses the steady cases and stays off). This kills most of the old "IK in-between" fear: the remaining corpus IK class is transition intervals only.
- Dynamics/ballistics cases are exact via the stored channels (keyed vertical); the keyless-vertical dynamics solve (ship_tank_karavan_mort_idle, 4.7 m) remains the one open runtime-solve item β the dataset's dyn cases all key the vertical channel, so it isn't pinned yet.
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:
- The Ξ΅-twist classes are Max 9 RUNTIME behavior, not file-format: the legacy rigs evaluated live in Max 9 show the same 0.0008 rad residues (clavicle/finger class) that the 2004 reference exports β which our legacy decode matches at 99.4% β do not carry. Consequence: for the .skel deliverable the 2004 reference stays authoritative; Ξ΅-class deltas vs the live GT are expected divergence on legacy files, and the fresh-format Ξ΅ handling (Β§10e) stays gated as-is.
-
Negative-scale (mirror-authored) markers: the 180Β°-flip class in the GT (box_arme_gauche, Babine/Bannane D, Box05-07 with scale (β0.8,β0.8,β1.17), the kami_guide_4 L toes) is a rotation-decompose CONVENTION divergence on reflected TMs between MAXScript
.rotationpartand 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_scaletwin rigs: every_big/_small/_slim/_sellevariant 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. -
Keyless-vertical is NOT a runtime solve: all four
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). -
Pelvis keys (
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). -
Toe BEND scalars cosine-ease like the toe bases (
b_fk_toebend, toeLinks:3 rig); finger bends stay TCB (b_fk_fingerbendat floor). Multi-link neck at floor. -
Open with data in hand: the true ease warp family (6-value sweep + a scalar-channel case recorded; classic-warp approximation leaves β€2.6 cm) and the IK blend-transition curve (
b_ikb_staticβ static COM, pure blend-in, 4.3 cm β andb_ikb_out5.3 cm; the in-rampb_ikb_longand 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):
- Hierarchy-robust anchors. Max 9 triangle-pelvis rigs parent the thighs AND the tail to the lowest spine link (legacy corpus: to the pelvis), but the thigh side offset and the tail attach matrix stay PELVIS-frame. The decode now anchors both through the pelvis bone's world matrix whenever the walk parent isn't the pelvis (era-independent by hierarchy, not by version; the parent-is-pelvis path is kept verbatim so the legacy corpus stays bit-stable).
-
Neck attach = spine end + pelvis-record translation. The pelvis record
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. - Clavicle anchors at the spine end in the LAST SPINE LINK's frame: world pos = spineEnd + R_lastSpineΒ·(f[9], f[8], Β±f[10]) (per-side halves as parsed). On straight rigs this coincides with the legacy walk-parent rule (and f[9]/f[8] β the pelvis-record offset, which is why humanoids masked it); the kitins' 22.5Β° neck bend discriminates (GT reproduced to 3e-4).
-
Chain-base a1 carries a baked +Ξ΅ (Ξ΅ = BIPED_EPS_TWIST = 0.0008 rad) on the matrix-based chains (spine/tail/pony1/pony2): the effective base rotation is
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). - Toe base ROTATION sides flipped between eras: legacy first half stores the LEFT toe rotation direct (right = LR mirror; the shipped legacy rule), fresh files store the RIGHT one direct (left = LR mirror) β discriminated by tr_mo_kakty (R toe 1.29 rad off under the legacy rule) and tr_mo_kami_guide_4 (both sides, asymmetric-edited). Toe base POSITION keeps left-direct/right-z-negate in BOTH eras (ryzerb-family GT: record z β0.4599 = L toe, R = +0.4599). Finger bases keep the legacy convention entirely (fresh halves are written symmetric).
-
Footsteps ground = (left) ankle height β leg-record
[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).patchFootstepsGroundpicks the rule by FigureVersion and now also keepsWorldTM/g_msBonescoherent 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:
-
Instances = selected nodes that are not accelerators (
(NEL3D_APPDATA_ACCEL&3)==0), not RPO zones, and isMesh (GeomObject superclass, Target (0x1020,0) excluded,NEL3D_APPDATA_COLLISIONexcluded) or isDummy (ClassId PartA 0x876234). Per instance: InstanceName (appdata, node-name fallback), Name =getNelObjectName(PSps_file_namefrom 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). -
XRef objects (ClassId PartA 0x92aab38c, superclass 0x60): record chunk 0x0170 = source file (0x0100, UTF-16, authored-era
R:\graphics\...path) + source node name (0x0110). Resolved headless by stripping drive +graphics/databaseand 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). -
Node TM: PRS controller values at t=0 (typed keyframer key tables bracketed at tick 0, else default-value chunks 0x2503/0x2504/0x2505 β the scale default is the full 28-byte ScaleValue, per-axis scale + axis-system quat), composed M = RΒ·SΒ·T in Max row-vector convention β scale applies in the PARENT frame (a rotated node with local scale (1.03,1,1) decomposes to (1,1.03,1), the fy_asc_1porte door class), with the axis-system stretch
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. MaxQuat::MakeMatrixis the TRANSPOSE of the standard quat matrix (the matrix-level face of the "controllers store the inverse convention" rule). -
Node chunks decoded: 0x0974 = wireframe color (BGR dword β two earlier cast-shadow hypotheses pattern-matched on it by coincidence; documented so nobody retries); 0x099c = rendering-control flags: bit 0x0200 = cast-shadows (β
DontCastShadow), bit 0x0400 = receive-shadows (discriminated by the canope class: receives-but-doesn't-cast). -
Point lights (decoded against the raw intermediates, light-by-light): Max light classes (0x1011,0) omni / (0x1012,0) target spot / (0x1014,0) free spot / (0x1013/0x1015,0) directional, superclass 0x30. Old-format ParamBlock (reference 0; 0x0002 entries with 0x0003 index and 0x0100 float / 0x0101 int / 0x0102 Point3 values β int values must be read as ints, NOT float bits; the same reader feeds the primitive pblocks below): param 0 = color (Point3 0..1), omni 6/7 and spot 9/10 = attenuation start/end, spot 4/5 = hotspot/falloff in degrees β NeL half-angle radians
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 realCPointLightNamedArray::build, whosestd::sorttie 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). -
Clusters/portals/links (the accelerator part, byte-validated against the intermediates): cluster = accel&3 == 2 (volume from
CCluster::makeVolumeper 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'sgetAABBox(shape resolved by basename through--ps-path, export-era shapes first) with the placeholder mesh as fallback, dummies test nothing (empty mesh, reference behavior). -
Mesh evaluation (
createMeshBuild+convertToWorldCoordinatereplication): 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). -
Edit Mesh modifier (class 0x50) decoded β the resolution of an apparent paradox (same Box class, cluster volumes bottom-face-first but portal polys = one side quad): the corpus portals/clusters are
Box + Edit MeshOSM 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. -
Direct-tier comparison classes (byte-identity deviations, whitelisted in
--mask-uninit): instanceSunContribution/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). -
Status (
ig_corpus.py --all --gate-t3, ctestpipeline_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 isTR_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 1budget). 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).
-
zonematerial/zonespecial:
exportInstanceGroupFromZoneruns withigName="", meaning every distinct ig name found in the file gets exported β structurally the same operationig_export.msperforms, just two differences: the ligo maxscript's owngetIglowercases the appdata value (ig_export.ms's does not), and output routes to<ecosystem>/ligo_es/igs/instead ofig_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_EGboth 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). -
zonetransition: one ig per grid slot (0..8), not per distinct name.
exportInstanceGroupFromZoneis called 9 times per file withigName = lowercase(zoneBaseName)(<a>-<b>-<c>-<zone>, 0-based, the same zoneBaseNamepipeline_max_export_zonealready builds for.zone/.ligozone) andtransitionZone = 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 viabuildTransitionMatrixObjbefore the normal instance-group build β replicating the maxscript literally settingnode.transformon the live scene nodes it just selected. The gate for running this at all (getTransitionZoneCoordinates,ok/findOnein the maxscript) is replicated asclassifyTransitionGrid: every non-frozen RklPatch node's bbox center (from its OWN un-evaluatedCRklPatchObject::decodePatchverts β 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.zoneexport) must fall in a validTransitionIds[y][x]cell. -
buildTransitionMatrixObj(distinct from zone's already-shippedbuildTransitionMatrix, Β§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 asmt Β· copyMtrather than overwritingmt, matching the maxscript returning(mt * copyMt)instead of assigning intomt). The MaxScript source callsscale copyMt [-1,1,1] true(a 3rd positional argument absent frombuildTransitionMatrix's 2-argscale 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 oneTransitionScale[4]=truemirror 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. -
Corpus-caught bug, fixed same session:
CReferenceMaker-style case handling aside, the FIRST bug the corpus surfaced was the lowercase-comparison mismatch above (68 Omni streetlights onconverted-164_egsilently dropped, shrinking a 5.6KB reference ig to a 250-byte one with only the 2 already-lowercase-tagged instances) β found by literally diffing--infodumps of the two files down to the missing point-light block, fixed by threadinglowercaseCompare=truethrough 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.psshape's β fixed by adding~/pipeline_export/common/sfx/psto the harness's search path (same conventionig_corpus.pyalready uses), which alone closed a third of the remaining diffs (e.g.forge.ig's Nel-particle-system cluster-link count). -
Open, not yet root-caused (documented, budgeted in the harness rather than silently passed): (1) selection-order divergence on a specific subset of files (desert
nb01..nb05, jungleforet-18..21_village_a/b/c/d, a couple ofilot_butteigs) β same instance COUNT and NAME SET as the reference, different SEQUENCE (confirmed by diffing--infolistings side by side: e.g. twoFY_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/3wells: 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. -
Status (
ligo_ig_corpus.py --all --gate-t3, ctestpipeline_max_ligo_ig_corpus): corpus = everyzonematerial-/zonespecial-/zonetransition-file underlandscape/ligo/<eco>/maxfor the 4 ecosystems (the same listingzone_corpus.pydrives) β 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/igsreference count exactly. 448/537 (83.4%) match: 71 byte-identical + 377 uninit-bytes-only (the sameSunContribution/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_zoraibundle files carry dozens of distinct ig names in one.maxand take up to ~70s each (every name re-walks the scene through the existingbuildInstanceGroupβ 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).
-
Typed RklPatch (
pipeline_max/nelpatch/):CRklPatchObjectclaims 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. -
Node selection = the maxscript semantics: delete
[NELLIGO]*helper nodes, take non-frozen RklPatch nodes (empty node chunk 0x0976 = frozen β the boundary-repetition reference zones), require exactly one (else error, likeselectAllPatch). -
Stack evaluation: OSM Derived wrappers with NeL Edit (0x4dd14a3c,0x4ac23c0c) and NeL Patch Painter (0x0c49560f,0x3c3d68e7) modifiers; per-modifier per-node local data = the wrapper's orphaned 0x2500 slots in reference order -> 0x2512 -> 0x1000 -> {0x1140 final PatchMesh, 0x4001 RPatchMesh blob (no rpoVersion prefix), 0x1130->0x1000 vert mapper}. Edit Patch applies
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 forcesoriginalStored=TRUEfor 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-pathRPatchMesh::UpdateBindingrefresh happens β stored data already carries the bits of Max's last interactive refresh; an env-gated long-double replication ofUpdateBindingPos/computeInteriorsexists in the tool (PMB_ZONE_REFRESH_BINDS) but is off by default because enabling it reduces corpus identity. -
Zone build replicates
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 NL3DCZone::build+ serial to CMemStream, patching version byte 5->4 (v4/v5 differ only in that byte, verified empirically). -
Ligozone protocol (filenames drive the semantics, all category strings lowercased):
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 throughCPatchInfo::transformwith the ecosystem smallbank. Mask =CZoneTemplate::buildreplication (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. -
NeL determinism fixes (uninitialized bytes leaking into .zone, fixed in the engine):
CPatchctor now zeroesFlags;CPatch::resetTileLightInfluencesnow setsPackedLightFactor;CBindInfoctor now zeroesNext[]/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). -
x87 tolerance tier: the reference plugin is a 32-bit x87 build;
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.pyclassifies these asx87passes. -
Budgeted deviations (3 of 1201):
zonematerial-converted-202_dy(two non-frozen RklPatch nodes β202_DY+ leftover copy202_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). -
Status (
zone_corpus.py --all --gate-t3, ctestpipeline_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. -
Outlook (the standalone patch painter / patch editor goal): the typed RklPatch class + the chunk-level PatchMesh/RPO decode already give full parse&modify&save on the .max side (T1/T2 roundtrip is byte-identical over all 1201 files); the export tool proves the read side is semantically complete. A painter needs the reverse of
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:
- The four ecosystem dirs (
landscape/ligo/{desert,jungle,lacustre,primes_racines}/max) hold 1231.maxfiles; the sweep takes 1201 and excludes 30. Every excluded file is a genuine non-target, verified against the reference maxscript +1_export.py:-
28 are
material-<eco>/transition-<a>-<b>ligo bank template files (they feed the ligo bank βprocesses/ligo/0_setup.pywrites aLigoBankDir/LigoMaxSourceDirectoryconfig over this directory), a separate input to the ligo system, not per-brick zone exports. The corpus driver'szonematerial-/zonetransition-/zonespecial-filter correctly keeps them out. Not defective β do not "fix." -
3 are source files that never match the protocol (see defective_max_files Β§1.8): two
zonematerial-files with a dash in the cell name (zonematerial-jungle-jungle-2,zonematerial-solprimer-solprimer-03_domeβ 4 tokens, the maxscript'stokenArray.count == 3and the tool'stokens.size() == 3both reject them), and the dev filecorrupted_moor_meta_goo(a development artifact named after the "corrupted moor" zone + the goo biome; nozoneprefix). No reference.zoneexists for any of the three (only a stale.max.tag).
-
28 are
- The headless filename filter (
main.cpp:tokens.size()==3 && "zonematerial"etc.) is the same rule asnel_ligo_export.ms'sfilterString ... "-"/tokenArray.countβ confirmed by reading the maxscript. So a brick the reference exported is exactly a brick we export. - The direct zone path (
--zone/ExportRykolZone,processes/zone/1_export.py) has zero corpus:ZoneSourceDirectory = ["landscape/zones/"+ContinentName]butlandscape/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).
-
Node selection (shape_export.ms::isToBeExported semantics, validated corpus-wide β 3588 name matches, we-only nodes fully explained): XRef-resolved base object superclass β {GeomObject 0x10, Shape 0x40}; skip
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'sNEL3D_APPDATA_LOD_NAMEentries, case-insensitive). -
ParamBlock2 storage decoded (scene_lib.cpp β the general mechanism, on top of the Β§10d PS decode): header chunk 0x0009 =
{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. -
NeL material decode (
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 viatTexture_1-8TEXMAP 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. -
Mesh path: EditableMesh GeomBuffers (0x0914 verts / 0x0912 faces β matID = faceFlags >> 16 β the geom_buffers.cpp field names
alwaysOne/smoothingGroupsare 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 throughCPlane * fromExportSpace+ normalize. -
Max render normals are ANGLE-WEIGHTED (discriminated against the references β plain and area-weighted sums both fail): per face per corner, weight = acos(dot of the two normalized corner edges); accumulate
weight Β· normalizedFaceNormalinto 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. -
Engine serial-compat additions (the reference plugin predates two 2024-2026 stream-version bumps; both write paths previously
nlstop'd):CVertexBuffer::SerialOldPreferredMemory/CIndexBuffer::SerialOldPreferredMemorystatic 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. -
Comparison verdicts (
--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. -
Map Extender bucketing: the custom UVW plugin (
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 (MAPEXTlines), 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). -
Status (
shape_corpus.py --all --gate-t3, ctestpipeline_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. - Standard materials decoded (same session, second round): StdMat2 ((0x2,0)) refs = { 0 null, 1 Texmaps (sclass 0x1080), 2 shader (sclass 0x10b0, Blinn (0x38,0)), own PB2s by blockId (0 = std2_shader: shader_type/wire/twoSided(2)/face_map/faceted; 1 = std2_extended: opacityType(0)/opacity(1); 3 = std_maps: mapEnables BOOL_TAB(0), maps TEXMAP_TAB(1)), sampler }; Blinn PB2 block 0 = ambient(0)/diffuse(1)/specular(2)/locks(3-5)/useSelfIllumColor(6)/selfIllumAmount(7)/selfIllumColor(8)/specularLevel(9)/glossiness(10). PB2 TAB records (type flag 0x800): record flag byte 0x00, then u32 count + per element u8 flag (0x40 = inline) + payload; TEXMAP_TAB inline element values are scene-container storage indices (β1 = none), not PB2 reference slots β resolved via getByStorageIndex (validated: the enabled diffuse slot resolves to the BitmapTex; colors + texture byte-match the reference material serialization). The reference resolves all these by param NAME through live ParamDefs with SubAnim recursion; headless keys on the serialization-stable (blockId, paramId) pairs instead.
- Verdict tiers refined: DefaultRotQuat compares double-cover aware (q β‘ βq; the x87 reference lands on the other representative at 180Β° sign boundaries β w β 0 flips whole-quat sign, ~17 files); VB normals get their own 1e-4 absolute tier (~0.006Β° on a unit normal β the angle-weighted accumulation amplifies x87-vs-SSE tails through near-cancellation sums; positions/UVs stay at 2e-6/32-ULP).
- Open, by measured diff mass (post-round-2, 383 differ): UVW Map (0xf72b1) + Unwrap UVW (0x02df2e3a) modifiers and the remaining unidentified modifier classes (0x40c7005e, 0x8ab36cc5, 0x8aad7d94) β UV-generating modifiers change VB dedup counts, the indexes+vertcount class; getLocalNormal residuals β₯1e-4 on multi-smoothing-group corners (fy_wea_lanceroquette 0.57 class β RVertex pick/merge on overlapping groups not fully pinned); parametric primitives as direct export meshes (reuse Β§10g buildParametricMesh + mapping coords); EditablePoly extraction; duplicate-node-name selection order (candide); milestone 2 = CMeshMRM (unskinned), CMeshMultiLod (418 skips), CWaterShape (221), CSegRemanence (82), CFlareShape (13), interface meshes (9); milestone 3 = Physique skinning β CMeshMRMSkinned (654) + Morpher blend shapes.
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_exportreference 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_affineis 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 likeMesh::buildRenderNormalslives 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.pyanswers "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
buildParametricMeshfrompipeline_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; extendgen_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,
buildMeshMultiLodin 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 wireCMeshMRM::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 (sharepipeline_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),getBSMeshBuildevaluates 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): typedCParamBlock2landed (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=387build 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 (acosin 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.
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:
-
keeps the raw chunks authoritative β the same discipline as
CControlKeyFramerBase/CRklPatchObject(Β§5, Β§12.2):parsedecodes a typed model over the orphaned chunks without moving them,buildre-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.maxnow parses through it regardless of class id. -
decodes the record model: header 0x0009 = { u32 scriptVersion, u16 blockId, u16 owner-marker, u16 0x2328, u16 paramCount, u32 ownerIndex }; each 0x000e record = { u16 id, u16 type, 10 opaque bytes, u8 flagByte, payload }. flagByte 0x40 = inline constant (float/int/bool/RGBA/point3/string per
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. -
supports in-place modify β
setFloat/setInt/setBool/setColorrewrite 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). -
write-direction proof:
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):
-
The corpus harness's T2 was a silent no-op for the shape and ig drivers.
shape_corpus.py/ig_corpus.pyinvoked the tester as[corpus_bin, path, "--parse"](flag after the path), butpipeline_max_corpus_testonly parsed leading--flags, so--parsewas 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--parsefirst. (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: -
CReferenceMakerdropped trailing empty (-1) reference slots on rebuild.build()emittednbReferences()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 (insfx/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 throughgetReference). 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).
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:
-
Rotation-controller class (TCB vs Linear): the 19
ControlRotLinearcases all need the fix, but so do 9 of the 131+9=140ControlRotTCBcases β class alone doesn't split the set. -
Key presence: every affected marker (both the working and broken sets) has
keyCount()==0β all are reading the same default-value chunk path, no key-table involvement. -
COM tilt magnitude (how far the rig's COM rotation deviates from the canonical β90Β°Z humanoid pose):
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. Failures cluster by template family (allkami_guide_3/4+kami_preacher_2/3/4share one failure signature; allryzerb/ryzoholok/c06/h07/h11share another;tr_mo_balduse/tr_mo_clapclapshare 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 onname/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.rotationpartand our decompose (Β§10e "Negative-scale (mirror-authored) markers") β not the same phenomenon as this 28-file set, which was diagnosed against the real.skelreference, 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.
10m. Anim corpus β VS2008/x87 gate wired, optimized-tier cross-build sensitivity found and the gate relaxed
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.
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):
-
dynamicsTypeis 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, onlydynamicsTypeflipped 0β1) landed at the identical evaluated COM position both ways. Doubly falsified β not the discriminator. - But the synthetic move cases (
b1ordinary move vsb2figure-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. - Cross-checking Max's own live-evaluated transform (not our decode) against the shipped references for
recruteur/meca/couture/decoupeshowed Max's live value matches ourbaseFramePos(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. - A follow-up question ("does this break anything else, what other files need checking") prompted auditing the horizontal channel the same way (a new
PMB_ANIM_DUMP_HORZFALLBACKdiagnostic, 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, includingrecruteuritself (all 3 variants, exact match) and upwards of 30 otherwise-unrelatedfy_hof_first_*clips (different tasks, different characters) that all carry the same ~5.3mmbaseFramePos.yoffset 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). -
Net revised conclusion: figure height (0x006c) is the correct general-case default for both channels β matching what's already shipped (the earlier fix was reverted in the same session it landed and never re-applied, so nothing was ever actually broken by this).
ship_tank_karavan_mort_idleandfy_hof_first_decoupe_end/_loopremain 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. -
Multi-biped file handling was also a real, separate bug in the probe itself, caught by the same "what else needs checking" instinct:
gen_biped_vhtsrc_probe.ms'sfindComNodepicked the first biped-like node found in the scene, silently conflatingtr_mo_kitin_queen's two bipeds (Bip01 + a nested Bip02) into one comparison. Fixed tofindComNodes(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:
-
New tool:
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.0flip β caught exactly this on one synthetic case, see below). Validated immediately against the existing~/biped_vhtsrc_probesynthetic files (b0βb7) and found real, previously-unrecorded facts on the spot:- An ordinary Move (no figure mode) updates four chunks in lockstep, not just the two already known:
0x0065,0x0104,0x0259,0x0260β a "current position" shadow-copy family, confirmed larger than previously catalogued.0x006c(figure height) is untouched. - A Figure Mode move updates all four of those plus
0x006ctogether, plus a small height-derived0x00ca[0]recompute (consistent with that chunk holding something gravAccel-adjacent, since gravAccel's documented default scales with height). -
0x0012is very likely the literal storage location ofdynamicsTypeβ a clean 0β1 flip on theb7(dynamicsType=1) case, nothing else. A newly-confirmed chunk mapping, even though the property itself turned out not to matter for this investigation. - A structural height edit rescales nearly the entire per-limb record set proportionally (
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 that0x01f4/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). - Scaling the COM node via ordinary MAXScript
scaleproduces 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. - One benign artifact caught by the bit-pattern fix: keying an unrelated arm bone with the timeline left scrubbed flips two floats in
0x0259from+0.0to-0.0β a sign-of-zero no-op, not a real signal. A separate tiny chunk0x0709(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.
- An ordinary Move (no figure mode) updates four chunks in lockstep, not just the two already known:
-
New generator:
gen_biped_skel_animode_probe.msβ loads a REAL skeleton file (fy_hom_skel.max, not a syntheticbiped.createNewrig 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:
-
Confirms and extends the "current position" family to four chunks, not two:
0x0065,0x0104,0x0259,0x0260move together on any ordinary move (0x006cuntouched); a figure-mode move updates all four of those plus0x006ctogether β same shape as the earlier synthetic-biped result, now confirmed on a real corpus-shaped skeleton too. -
0x0012is very likelydynamicsType'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. - Every one of the 8 combined-toggle cases is an exact union of its components' individual effects β no hidden interaction anywhere. Thorough confirmation there's no combinatorial gotcha being missed.
-
talentFigMode/lockComare global Character Studio session preferences, not per-file data β they read back as set (the MAXScript assignment worked) but produced zero file changes, andlockComwas observedtruein a case that never touched it (carried over from a prior case in the same Max session, sinceresetMaxFiledoesn't reset them). Ruled out conclusively, and for a more interesting reason than "no effect" β they're never saved to the.maxat all. -
The
key-then-deletecase (b08/c04) was a bug in the test script, not a finding:biped.setTransform com #pos ... truekeys both the horizontal and vertical sub-controllers, but the atom only captured and deleted the vertical one's key β the resulting ground truth showsnumKeys.horizontal = 1, i.e. a real, live, un-deleted key, not a "ghost" residue of a properly-deleted one. The0x012c/0x012dsize growth is exactly that live key's storage, nothing more subtle. - No case β single or combined β surfaced a chunk holding a value distinct from the already-known
0x006c-vs-current-position-family split. Negative but real: the mechanism isn'tdynamicsType,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:
- Track View shows
Verticalas 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 showingBip01 Pelvis, a different bone, notBip01's own Vertical sub-anim). - The dashed line's displayed value is mode-dependent: Figure Mode reads exactly
0, -0, 6(0x006c); Animation Mode reads exactly0.609, -1.984, 1.327β the actual reference value β matching neither0x006cnor the stored0x0104-family current position (1.275). - Moving
Bip01outside Figure Mode (confirmed via--diff-rigto change the stored0x0065/0x0104/0x0259/0x0260current-position chunks) and then toggling Figure Mode still converges back to exactly0.609, -1.984, 1.327β ruling out a simple chunk read. -
Deleting
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. -
Adding a real vertical key, then removing it again, was the deciding test: with the key present, the value follows the key as expected; with it removed, Animation Mode reverts to exactly
0.609, -1.984, 1.327every 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.
10o. The unkeyed COM is the current-position frame, not figure height β ship_tank resolved, Move All (0x0117) decoded
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:
-
Figure height is independent of the animation-mode value. Changing the figure-mode COM Z to 5 leaves the non-figure COM at
1.327. So the exported (animation-mode) value cannot be figure-derived. -
"Delete all curves" (curve editor) keeps it at
1.327β the--diff-rigshows only the keytrack chunks (0x012c/d) shrink; the current-position family (0x0104/0x0260/0x0065/0x0259) is untouched. The baseline is not in the curves. -
"Clear All Animation" reverts it to
0,0,6β and--diff-rigshows 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.
10p. Field probe results β the shadow bank, the length tables, and a batch of chunk identifications
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 changed0x0115from 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_posand everyfNN_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/93sep-arms/legs/spine/neck each flip an entry; tail/pony didn't take on this rig). -
0x014b= inPlaceMode;0x0109=twist enable/linksthe FIGURE MODE flag (corrected 2026-07-08, authoring session: the single-variable isolation diff fy_hom_skel vs the animode probe's b00_baseline β whose ONLY change is thefigureMode=falsecommit β 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.
10q. Biped IK-interval solve β measured A/B, the E3 legs-only variant, and why baking is the only lever
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):
- Blend gating: an interval is pivot-constrained iff BOTH its keys have ikBlend β 1. The corpus's 0β1 and 1β0 transitions evaluate pure channel-FK (fy_hof_a_course's heel-strike approach frames are FK-exact to 5 decimals) β unlike the scripted body-space dataset keys (b_ikb_*), whose ramps deviate from FK (the Β§10d-bis open transition class, unchanged by this session).
-
The ankle is slaved to the foot rotation through the rigid pivot arm:
ankle(t) = W(t) β R_foot(t)Β·pLocal, withpLocal = 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(t) β the pivot's world path β is a TCB vec3 spline over its per-key positions (the leg track's per-key TCB/ease params, biped boundary rule at session ends; a 2-knot session degenerates to exact lerp, which is what the heel-strike interval shows). Sessions = maximal runs of consecutive planted intervals sharing the interval-START key's pivot index. A pivot-handoff key (idx changes) contributes a terminal knot IFF its stored pB is arm-length-consistent with pLocal (the storage's own signal that the old pivot kept moving into the handoff β fy_hof_a_course R's roll, verified float-exact, vs the L-side counter-case where the channel clamps instead, also verified). A later same-pivot key whose stored pA is no longer arm-consistent is a RELEASE β the session truncates before it (the pivot record itself says the foot peeled off).
-
Knots are COMPUTED from the channel state at key times (
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. -
The coverage gate β the D-window: per interval, the pivot's effective end-to-end motion
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). -
The 2-bone thigh/calf solve places the ankle on the target (align + law of cosines, knee = Z-hinge, FK knee plane preserved), hard (no alpha blend). A latent sign bug in the E1/E3-era flexion correction was found and fixed on the way: the
(phiT β phiNow)Β·sgnform withsgnfrom 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 by2Β·phiT(zo_hof_marche's straight-leg release frame, a 0.24 rad ankle miss). Replaced with the robust signed-angle formatan2((thXΓu)Β·hz, thXΒ·u) + phiT. -
The foot's own world rotation inside plants is NOT the FK composition: the stored foot quats are COM-relative snapshots, and composing with the time-varying COM(t) drags a planted foot along with the moving body (fy_hof_co_l2m_coup_fort's 32-frame plant: the FK-composed foot swings 0.30 rad while the reference stays put; fy_hom_emot_a_cheer 0.447 β 0.013). A pure world-space interpolation over-corrects the other way on net turns (the planted foot genuinely swivels with a half-turn: fy_hom_a_demitour_go world-squad is 0.75 off), and a LIVE-COM yaw frame injects the gait/gesture pelvis wobble into planted feet (cheer 0.45 under it). The shipped default interpolates BOTH parts from the keys: the COM's yaw (world-Z swing-twist β smooth under body pitch, unlike an axis-heading extraction, which the mort/death class killed) as an UNWRAPPED SCALAR ANGLE channel (quat interpolation of the yaw hits the double cover on >90Β° per-key turn steps and goes the wrong way around β fy_hof_co_mn_demitourgauche's half-turn put the planted foot 0.69 off before the unwrap), composed with the yaw-frame foot residual channel β
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|offfor A/B. -
Arms: the same machinery generalizes (the arm record tail has the same layout; the gesture-class pivot is the wrist itself,
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 behindPMB_BIPED_IK_ARMS=1for a future arm-pin investigation. The coup_fort strike class (hands pinned on the weapon, worst 0.66) is the largest remaining single class. -
A pre-existing COM turn defect found and fixed on the way (all modes): the 0x012c/0x012e turn record stores BOTH the quat
[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 decompositions(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:
-
Decode the arm end-effector space per key. The
[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. -
Then re-enable the limb-generic machinery (
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).
- Payoff estimate: the rotation floor is 0.01β0.1 rad on most planted frames AND it feeds the ankle target through the pivot arm, i.e. it currently costs both the foot AND (via near-extension amplification) the calf/thigh on the worst frames.
(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:
-
Probe Max's true V/H/T turn interpolation (extend the fields probe: single-track turn keys at 30Β°/90Β°/150Β°/210Β° steps, varied TCB, GT dump) to replace the current heuristic gate (
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. -
Localize our residual COM error on the regressed files: export with
PMB_BIPED_IK=0, cmp the COMrotquattrack 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.
- Caveat from Β§10r: the corpus's own 0β1 approaches measured channel-FK-exact on course, so if the dataset says "weighted mix" while the corpus says "FK", trust the corpus for defaults and gate by the space flag
[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:
-
Foot pivot ids are a per-foot table, consistent across files: piv 5 = ball, piv 2 = heel (bye legR piv5/legL piv2; victory legR piv2+piv5, legL piv5 β same world positions at the same pose across files). The pivot arm mirrors between feet in the FOOT frame β
pLocal_L = (x, y, βz)ofpLocal_Runder 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-5pAexactly. - Stored quats must be sign-continuous across keys: the record inversions return arbitrary hemispheres; a hemisphere flip between adjacent keys makes the TCB squad take the long way around (a 0.5 m mid-interval pop on the first run). Character Studio ships sign-continuous sequences; the generator sign-chains every stored quat field per track.
- Squad overshoot on fast reversals: a 137Β° upper-arm segment through the punch apex needed an extra in-between key (f28) β dense keys at direction reversals, not TCB parameter tuning, is the robust fix.
- Toe-off reachability is the binding constraint on the launch ballistics: the last planted key must keep hipβankle within L1+L2 with the ankle raised by the ball-pivot roll; the L leg's near-straight idle knee (2.98 rad) leaves no extension headroom, which is what forces the ball pivot (the heel pivot's short arm cannot lift the ankle) and sizes the toe-off COM height (0.985 m).
- Write-after-walk hazard: a scene that was rig-walked (AppData parsed on demand) rejects the clean/build cycle β write through a freshly loaded scene instance (the corpus-proven parseβeditβbuild path).
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
0x0210asuint32 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 SDKMeshDeltalayout 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_decorsnode intr_hall_vitrine_reunion.maxdeletes 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β agen_decors_probe.msMax 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/3reference 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: read0x0130inreadEditMeshModApp(16-byte stride, skip the srcTag), retire the0x0210reader (any file that only carried0x0210didn'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, jungleforet-18..21_village_a/b/c/d, someilot_butte) came from two independent issues:-
Tree-order walk vs storage order.
$geometry/$lights/$helpersin 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 asbuildTreeOrder(ssc, tmCache.SceneRoot, orderMap)β build parent β children list in storage order, then DFS pre-order fromssc->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. -
The ligo-only XRef pre-pass matters for ordering.
exportInstanceGroupFromZoneinprocesses/ligo/maxscript/nel_ligo_export.ms(unlikeprocesses/ig/maxscript/ig_export.ms) has an extrafor node in objects where classOf node == XRefObject do ... selectmore nodeloop BEFORE the three geometry/lights/helpers passes. Whether this changes ordering depends on whether MaxScript's$selection as arrayreturns inselectmoreinsertion order or in Max scene order β Max SDK documentation is silent. Both hypotheses were measured against the corpus:- "returns scene order" hypothesis (XRef pass ordering-inert, drop it): 88 differ
- "returns insertion order" hypothesis (XRef pass runs first): 28 differ
β the corpus decides: keep the XRef pre-pass for ligo mode. The standalone
processes/igpath doesn't have this pass and its caller passesincludeXRefFirst=falseβ its 54/54 result is independent evidence that the geometry/lights/helpers passes alone are the right shape for that path.
-
Tree-order walk vs storage order.
-
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 ownpss->getAABBox+ node transform) and REGRESSES corpus (Matis hall_conseil / hall_vitrine_hall_reunion lose expected cluster links, direct diff 2 vs the prior 0). KeepCAABBox::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):
- Standalone (
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 1budget 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 (
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 = 89untouched (any future regression past that still fails; tightening is a follow-up).
- Standalone (
-
Remaining 28 ligo diffs classified (not root-caused this session β read this as the current punch list, not settled ground):
-
~5 files, precision-only cluster containment on FX-instance edges: the AABBox corners land within
CLUSTERPRECISION = 5mmof a cluster plane on borderline meshes (fy_sheriff/fy_warschool/street/taverneβ extra cluster links onBrazierB.ps/similar). The reference plugin is x86 x87, we're x64 SSE; per Β§2b these agreed oncmbbut 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). -
~23 files, XRef-pre-pass over-inclusion β CLOSED same session (2026-07-08 evening). All these files have every node at a single-tree-root level, so
buildTreeOrderdegenerates to storage order and doesn't help. Thexref_resolvabilityblock of Kaetemi's Max 9gen_ig_selorder_probe.msrun pinned the actual maxscript rule:GetSourceObject falsesucceeds 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 atR:\graphics\...\fy_cn_module.maxwhose corresponding nodes are themselves XRefs pointing toR:\...\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 aSplineShape/sClass=shape(0x40) β skipped. Both classes of skipped XRef fall through to the per-category$geometry/$lights/$helperspasses 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 throughbaseObjectOfObj, so we peel back one step and stop atnode->getReference(1), then unwrap OSM/WSM derived-object wrappers to seesuperclassOf sourceObjectas 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_BUDGETre-tightened28 β 8. Remaining 8: ~5β6 files with the precision-only PS clusterize AABBox class (above), plus 2β3 unclassified singleton file cases (taverne.ig Scale field, jungle-2.ig Pos, fy_cn_fortress_a_brandon.ig Scale+Clusters) β not yet triaged.
-
-
~5 files, precision-only cluster containment on FX-instance edges: the AABBox corners land within
-
Instrumentation added: the exporter's
--dumpmode now emitstree=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 onTR_hall_reu_vitrine_decorsandfy_mairie_puit_carre3to pin the 0x0130 diagnosis).
10x. CMB export closes gaps β MDELTA_CHUNK decoded, XRef resolution shared, ig/cmb Edit-Mesh common code
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 legacyTOPO_NFACES_CHUNK(0x2775) andTOPO_NTVFACES_CHUNK(0x2776) respectively. Pinned byfy_hall_reunion: reference.cmbhas exactlyinput+kept β delFaces + 0x0208.count = 248faces on the failing node, applying0x0210to the base mesh produces edge-inconsistent topologyCCollisionMeshBuild::linkrejects 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 legacyTOPO_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 byfy_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:
- Runs
applyFaceAttribs(e, faces)before the sharedapplyEditsβ matID and per-edge visibility bits (in the sameVis0/1/2 β EDGE_B/C/Aremap the base-mesh decode uses) update the input face set. - Resolves XRefObject collision nodes in
--ligomode via the sharedXREFRESOLVE::resolveXRefObject(the exact code ig has been using; every ligo brick's collision nodes are XRefs into shared source.maxfiles, so Β§10v's "6 of 1201 files produce zero output" gap closes automatically). - Downgrades
CCollisionMeshBuild::linkerrors 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.
-
Created-vertex position offset on
FY_hall_reunion(16 verts, ~10 unit systematic Y offset). Candidate:0x2510mod-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. -
The one face-index remap on
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 legacyTOPO_FACEMAP_CHUNK0x2780) 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 (Ypreserved,X/Zdiverge, rotation-about-Y suspected, precision ruled out). - Ligo tier's 16 export failures β triage against the link-error tolerance mechanism; some will resolve when the face-remap chunk lands.
- The wiki's Part L update includes the auth-doc's modifier-stream (Β§ L.4) and local-data-stream (Β§ L.5) framing chunks, plus the legacy-format conversion procedure (Β§ L.6) β none of which pipeline_max writes today; folded in for the day a full modifier-authoring path needs them.
10y. Shape session β Edit Mesh shared decode, CMeshMultiLod, lightmap-mask verification, parametric-prim shapes
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:
-
Physique skinning β
CMeshMRMSkinned(654 files, the biggest remaining not-produced class). Per-vertex bone weights in Physique's per-node mod-app (undecoded); bone name/order viabuildSkeletonShape's nodeMap (share viapipeline_max_export_common). Reference: Β§10's IPhyRigidVertex + IPhyBlendedRigidVertex notes. -
CWaterShape (221).
CWaterShape::setShape(CPolygon2D)+setWaterPoolID+setEnvMap/setHeightMap/setColorMapfrom the water material's PB2.hasWaterMaterial(node)already identifies these; the buildWaterShape path is unimplemented. - CSegRemanence (82), CFlareShape (13), interface (9) β smaller M2 tail; flare is the nel_flare scripted-plugin PB2 (Β§10g-nel_ps pattern), remanence is USE_REMANENCE appdata + segment geometry, interface is INTERFACE_FILE border-weld.
-
Lightmap-diff (281) β the reference has structural divergence beyond just calc-lm additions on these. Candidates: multi-lod slave material merging (the
materials: 3 vs 4class), some primitives producing coarser mesh than the reference expected, Unwrap UVW effects not applied (mod:02df2e3a, 25 uses). - UV mapping-coord generation for parametric primitives β the 27 new differ files. Requires the per-primitive UV formulas (Max SDK); Box/Cylinder/Sphere/Plane each have their own generator and checkbox state.
-
UVW Map (
0x000f72b1, 76 uses) β the biggest still-unhandled modifier; standard-plugin planar/cylindrical/spherical/box UV projection. -
Unwrap UVW (
0x02df2e3a, 25 uses) β user-authored UV overlays. -
FFD(box) (
0x8ab36cc5, 20 uses) β Free-Form Deformer lattice. -
Nel VertexTreePaint (
0x40c7005e, 110 uses) β our own plugin, vertex color paint modifier; would need lookup inplugin_max/nel_vertex_tree_paint. -
multi_mtlslave-material merging (common/construction/gen_mo_charognemammalclass) β 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:
-
Round 1 (CTextureBlend + stream v4 downgrade). Reference above-water envmap in every corpus water shape is a
CTextureBlend(day, night)wrapping both the primary and secondary env texmaps; my initial ship built a plainCTextureFilefrom slot 1 alone, ~125 bytes short of reference. Rule fromexport_mesh.cpp:1879-1911: whenever the SECONDARY envmap texmap is present (slot 2'stTexture_2reference), the reference exporter wraps BOTH slots in aCTextureBlendregardless of the enable-slot flag β this is what drives day/night blending viaCWaterPoolManager::setBlend. Applied to both above- and under-water pairs. Independently: reference-era exports areCWaterShapestream version 4 (2004 plugin), the current in-tree write path emits 7 β v5-v7 addRealtimeReflection+ fresnel bias/scale/power +EnvMapCalcReflectivityat 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 inmain.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: OURtr_water_00.shape587 β 698 bytes, exact size match against reference; byte diffs 111 β 35, remaining diffs concentrated inHeightMapscale/speed + a small tail-bool region. -
Round 2 (correct water-param IDs + tail bool defaults). Turned out the v14-water-material NLB_MAIN param IDs I initially guessed at (0x25..0x2e for
fBumpU/VScale/Speed,fDisplace*,fWaterHeightFactor,iWaterPoolID) were the STANDARD NeL material's later params, not the water script's earlier ones. APMB_WATER_DUMPenv-gated dump againstWaterBassinA02(inwatertrykerwhole.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 forbEnableWaterSplashandbWaterUseSceneEnvMap{Above,Under}remains unmapped in-code β my initial guesses (0x19/0x1a/0x1b) turned out to beiBlendSrcFunc/iBlendDestFuncfrom the standard material's block layout that this material also carries. After round 2: byte diffs 35 β 3. -
Residual 3 diffs at file offsets
0x29d,0x2a1,0x2a5β the MSBs ofDefaultRotQuat'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'sdecompMatrixpath 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'scompareShapesFieldspath 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:
- Physique/Skin β CMeshMRMSkinned (654 files, still the biggest remaining not-produced class; the Opus-scale Session-3 handoff item from Β§10y is unchanged and now stands out as the sole big frontier).
- Water DefaultRotQuat 3-byte residual β see the residual bullet above. Compare-mode field-walk for CWaterShape (analogous to compareMeshBase's CMesh path) would promote most of the 219 water-differ files to FLOATEQ.
-
EditablePoly map channels β the 13 files in the EditablePoly differ class. MNMesh per-poly map channels aren't in the tri path's
0x2394/0x2396layout; need the Part C<PolyObject base data>MNMesh sub-container decode (Part C explicitly leaves this "out of scope" for the current format-reference). -
Residual 14 mesh-eval failures β non-EditablePoly object classes still logged as
obj:0000000a,obj:00001040,obj:00001065. Identify each viamaxole.pyon a source.maxfrom 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'smesh-evalbucket entry was already down from a much larger baseline post-M2; these residuals may be non-shape carriers. -
Nel VertexTreePaint (
mod:40c7005e, 110 uses) β our own plugin (plugin_max/nel_vertex_tree_paint/). Local-data storage layout: chunk0x0100= version int,0x0120=int32 numColors + numColors Γ COLORREFper-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 inside0x2512 β 0x4000per the framework wrapping, analog to Edit Mesh'sMDELTA_CHUNKbut 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 viamaxole.pya 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:
-
Physique/Skin β CMeshMRMSkinned (654 files) β still the sole big frontier. Physique/Skin modifier per-node payload storage is undocumented in
max_geometry_formats.md(Part L covers Edit Mesh'sMDELTA_CHUNK; no analog for the skinning modifier-app slot yet). Referenceexport_skinning.cpp:444+uses Max SDKISkin/IPhysiqueExportinterfaces β no headless equivalent. Path forward: (a) locate a small skinned corpus file, dump its OSM Derived wrapper's0x2500payload 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_datasetetc.) and byte-diff to pin the per-vertex weight layout, (c) integrate withpipeline_max_export_common/biped_rig's bone-name/order infrastructure, (d) wireCMeshMRMSkinned::buildwith per-vertex weights + bone name array. -
Water DefaultRotQuat 3-byte residual β the FLOATEQ walk lands them in the correct bucket now; upgrading them to byte-identical still requires resolving the
decompMatrixsign-boundary. Same class as the CMeshDefaultRotQuatsign-flip (Β§10i). Unlikely to be fixed without the VS2008/x87 reference-linked SDK object code (Β§2b handoff). -
CSegRemanence (82 files) β needs Max SplineShape/Line class chunk decode (undocumented). The reference
buildRemanence(export_remanence.cpp:43) samplesso->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. -
Interface-mesh (9 files) β needs cross-.max interface-file loading (
BuildMeshInterfacesinexport_mesh_interface.cpp) + border normal welding within a distance threshold. Reference atexport_mesh_interface.cpp:695+. - EditablePoly map channels (13 files) and Residual 14 mesh-eval failures β unchanged from Β§10z.
- Nel VertexTreePaint (110 uses) β unchanged from Β§10z.
10z-ter. CMB coverage β 0x0210 = face-vertex remap (not created map-1 faces), EditablePoly + parametric-prim collision meshes, ligo tier gated
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:
- T1/T2 corpus: 8632/8632 unchanged (this session touched no library-side chunk decoders β the 0x0210 reinterpretation stays inside the mod-app payload consumers, and the base-mesh-type dispatch is on the export tool's evaluation side).
- Direct tier:
pipeline_max_cmb_corpusT1/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_vitrineface-index similarly improved.Zo_bt_Hall_Conseil's 80/80-vertX/Zdivergence withYpreserved is unchanged (open on its own terms since Β§10v β the diagnosed rotation-about-Y class, precision ruled out via VS2008/x87). - Ligo tier: T3 32 byte-identical + 84 float-eq + 105 differ + 0 export failures (
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 tier
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.maxfiles define vianel_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/cmbvillage_nb_0x/selection-order class documented on the same brick set. -
Direct-tier
FY_hall_reunion16 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 infy_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_Conseilvertex offset β unchanged from Β§10v/Β§10x.Yexact,X/Zdiverge; rotation-about-Y suspected, precision confirmed non-causal (Β§10v). Open on its own separate terms. -
Unregistered scripted plugin
(0x51db4f2f, 0x1c596b1a)β 12 corpus files carry this on Matis collision meshes. The class lives in some.maxfile's ClassDirectory3 as a scripted plugin, and its base-object semantics are unknown until located and decoded (probably a NeL-side plugin likenel_pacs_box-family). -
Design-doc
max_geometry_formats.mdΒ§ L.2 (Part L) β the0x0210 = created MAP-1 TEXTURE-VERTEX facesrow is now known to be wrong; the corpus fitsface-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).
10z-quat. Shape session β Physique mod-app characterization, ClassID-based detection, warn-class discipline
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:
- Mod-app chunk framing is IDENTICAL to Edit Mesh (Β§10x Β§L.1):
0x2510mod-context TM (52B) +0x2511ctx bbox (24B) +0x2512payload container +0x25134-byte trailing state. Physique's payload container0x2512contains one child0x2504(Edit Mesh has0x2512 β 0x4000 MDELTA_CHUNKinstead β different). - Inside
0x2504: fixed 4-chunk header (0x250112B version+flags,0x2502deformable-space transform container,0x250917B global params including the313.0fsentinel float and two0xffffffffmarkers,0x2802reference-pose snapshot byte-identical to0x2502) followed bynumVerticesinstances of0x2506container β0x0989leaf. - Each
0x0989per-vertex record:uint32 numBones(2 or 3 on the corpus samples) +numBones Γ 20 bytesper-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:
-
Physique payload decode (654 files, still the biggest single remaining coverage frontier). Prerequisites documented in Part M Β§M.3. The differential-dataset generator is the practical next tool β same channel as
~/biped_dataset,~/prim_mesh_dataset,~/biped_anim_dataset*.PMB_SKIN_DUMP=1is available for the follow-up decode work. -
Skin modifier (0 files in the corpus that we've seen β every skinning modifier corpus-wide is Physique; the Max 3-era authored assets predate Max 4's Skin modifier). Deferred until a corpus file appears; same
PMB_SKIN_DUMPdiagnostic will characterize it. -
CSegRemanence (82 files) β still needs Max SplineShape/Line class chunk decode (undocumented in
max_geometry_formats.md). -
14 mesh-eval failures β now split into shape-class:0a=7, shape-class:1040=2, shape-class:1065=5 (SplineShape/Line/Rectangle). These would need the same SplineShape decode as CSegRemanence, potentially routing through a
buildRemanencefallback if the reference actually produces.shapefiles for them; more likely they're accepted-then-ignored by the reference at some level. - Interface-mesh (10 files) β unchanged from Β§10z-bis (cross-.max interface-file loading + border normal welding).
- EditablePoly map channels (13 files) and Nel VertexTreePaint (110 uses) β unchanged from Β§10z.
Fixed (2026-07-05, biped roundtrip-coherency session β these two are why T2 jumped from 2/169 to 168/169 on the biped corpus):
-
(builtin/storage/app_data.h/.cpp). The Max exporter does not insert AppData entries in that sorted order (real files interleave entries in whatever order the plugin created them), so any file whose entries weren't already sorted got its AppData chunk reordered β byte-identical in aggregate content, wrong in chunk sequence. This was the single highest-impact defect found this session: biped rigs carry dozens of per-bone AppData entries, so nearly every bipedCAppData::builditeratedm_Entries(astd::map<TKey, CAppDataEntry*>), silently re-sorting AppData entries by(ClassId,SuperClassId,SubId)on every rebuildNodeobject hit it. Fixed by addingm_EntryOrder(astd::vector<CAppDataEntry*>tracking parse/creation order) thatbuild()iterates instead of the map;m_Entriesremains the map used for keyed lookup (get/getOrCreate/erase), now kept in sync withm_EntryOrderat every mutation point. -
(scene.cpp) β everyCSceneClassContainer::createChunkByIdignored thecontainerbool (the file's own container-bit for that chunk) and always returned aCSceneClassCSceneClassis aCStorageContainer, whoseisContainer()is hardcodedtrue, 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 totrueon 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:IStorageObjectgained avirtual bool writeAsContainer() const(default{ return isContainer(); }), andCStorageContainer::serial(CStorageChunks&)'s write loop now callswriteAsContainer()instead ofisContainer()for the wrapper's container bit.CSceneClassoverrides it via a newm_ReadAsLeafflag, set byCSceneClassContainer::createChunkByIdwhenever the file'scontainerparam isfalse, and exposed for other classes that might need the same escape hatch viasetReadAsLeaf().
Fixed (2026-07-05, in the same session that stood up the pipeline_max native build):
-
(storage_value.cpp) β now writesCStorageValue<std::string>/<ucstring>::getSizereturned size as bool, never wrote the out-parametersizeand returnstrue. Same bug class in(storage_array.h) β now propagates the inner call's true/false. Latent, since chunk sizes are back-patched by seeking (Β§3); harmless before the fix, still fix-first material for any futureCStorageArraySizePre<T>::getSizeadding 4 to a bool returngetSize-relying code path. -
(scene_class.cpp:271) β nowCSceneClass::putChunkinserted intom_OrphanedChunkswith an iterator borrowed fromm_Chunksm_Chunks.insert(...). Prior behavior "worked" becausestd::listsplices the node into the iterator's owning list, but left both lists' cached sizes inconsistent, which quietly skewed thedisownsanity checkm_Chunks.size() < m_OrphanedChunks.size()β reliably surfaces as an "Not built" false alarm during parseβbuildβdisown on real corpus files (observed onge_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. -
(scene_class.h:169) β passesCSceneClass::getChunkValuenlerror("β¦ 0x%x β¦")with no format argument(uint32)idnow. -
(CTrackViewNode::parsecopy-paste on 0x0150 chunk checkif (m_Empty0150) nlassert(m_Empty0140->Value.empty())) β now derefsm_Empty0150->Value.empty(). Latent nullptr crash if 0x0150 appears without 0x0140. -
β now guarded withCStorageRaw::serial+CStorageValue<string>/<ucstring>::serial+CConfigScriptMetaString::serialtook&Value[0]on possibly-empty containersif (Value.empty()) return;(or wrapped for the null-terminated case).serialBufferno-ops on len 0 so it was benign; UB by the letter of the standard. -
(scene_class_unknown.cpp:75) β addedCSceneClassUnknownDesc::createhad no return statement afternlassert(false)return NULL;. Path is unreachable (unknowns go throughcreateUnknownon the superclass desc) but a release build would fall off the function end.
Fixed (2026-07-05, biped corpus-completion session):
-
Superclassβ every0x1190("Depth of Field (mental ray)") unregistered,nlerror-aborting the whole process*_fp.maxfirst-person-hand file in the corpus carries this superclass (a camera/render effect, irrelevant to skeleton export) and crashedpipeline_max_export_skeloutright. Fixed the same way as every other unimplemented superclass observed in the corpus: addedCCameraEffectSuperClassDesc(CSuperClassDescUnknown<CReferenceTarget, 0x00001190>) toCBuiltin::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. -
Mixed 32-/64-bit chunk headers in the same stream widen everything to the outermost chunk's width on writeβ the last remaining T1/T2 failure in the skel corpus (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:IStorageObjectgained a tri-statewasRead64BitChunk()/wasRead64BitChunkKnown()pair (set byCStorageContainer::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 ownm_Was64Bitaggregate only for chunks a typed class rebuilds from scratch duringbuild()(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 stickym_Is64Bitflag 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::parseassumes 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. -
64-bit chunks not writableβ now writable (2026-07-05), refined the same day (see "Fixed" above β the initial per-container flag under-covered mixed-width containers, now per-chunk).CStorageContainergained a per-containerm_Was64Bitflag set on read fromCStorageChunks::is64Bit(); the top-levelserial(stream, size)(and the gsf-path variant) round-trips it throughCStorageChunks::set64Bit()on the write side.CStorageChunks::enterChunk/leaveChunknow emit either a 6-byte 32-bit header or a 14-byte 64-bit header per-chunk (HeaderSizefield onCChunktracks 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. -
Failure =
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). - Empty-AppData drop β the one whitelisted normalization (Β§5). Any future intentional normalization must be added to that whitelist and this list, in the same commit that introduces it.
10z-cinq. Parametric primitive per-side matId + smoothing groups β corpus-validated against ~/shape_export_dataset
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.
10z-six. Physique bone table located, primary boneRef decoded β the M.3 blockers #1/#4 closed without a differential dataset
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):
-
~4 % of boneRefs are small positive values (e.g.
0x0000004d= 77), not~index. These are Physique deformable cross-links β the stored per-vertex data is the DEFORMABLE link tree (each record isnumBones Γ {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. -
The
Bip01(COM) root-link discrepancy. The referencefy_hof_armor01_pantabottes.shapelistsBip01as bone[0] with MANY rigid vertices, butBip01is NOT among the modifier's 87 references β those vertices are produced by the SDK'sConvertToRigid(TRUE)(called at line 636 ofexport_skinning.cpp) promoting deformable vertices to the rigid ROOT link. The reference's.shapeSkinWeights come fromIPhyBlendedRigidVertex::GetNode/GetWeightAFTER that conversion, so reproducing them headlessly means replayingConvertToRigid's link-hierarchy logic β the same SDK object code that resolvesGetNode. 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.
10z-sept. Physique production path + parametric Box/Plane UVs β the skinned frontier closes for coverage
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-app0x2500 β 0x2512 β 0x2504 β N Γ (0x2506 β 0x0989), reads{u32 numBones, numBones Γ {u32 boneRef, Point3 offset, float weight}}, resolvesboneRefvia~indexinto 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),orderedChildrenOffor Max scene order, duplicate names get_Second(matchesbuildSkeletonShape). -
applyPhysiqueSkinningβ top-4 highest weights, normalize by sum, fillCMeshBuild::SkinWeights+BonesNames; vertices with no resolvable influence fall back to skeleton root weight 1.0 (the ConvertToRigidβBip01 promotion class, coverage approximation). - Shape exporter: Physique is no longer a hard SKIP; after
evalAndBuildMeshthe weights are applied andPaletteSkinFlagis set; skinned meshes force the MRM branch soCMeshMRMSkinned::isCompatiblecan fire. Skin (Max 4+) still skips (zero corpus). Physique/Skin treated as geometry-neutral inmesh_eval(no unhandled-modifier warn β the reference disables them before mesh eval). -
Interface-mesh was a false hard-skip: the reference applies border normal welding as a post-build correction, not a gate. Nodes with
INTERFACE_FILEappdata 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):
- Skinned DIFF quality β ConvertToRigid full replay (needs differential dataset or SDK-side dump of post-conversion GetNode/GetWeight), MRM era tolerance tiers, interface weld.
- CSegRemanence (82) β Max SplineShape/Line decode (also unblocks the 14 mesh-eval shape-class residuals).
- UVW Map (272 uses), Unwrap UVW (38), FFD(box) (20) β modifier tail.
- Cylinder/Sphere parametric UVs; StdUVGen static matrix (waterfall material ~48-byte class).
-
.veget/.clodβ surface completion;.clodreusesPHYSIQUESKIN.
10z-huit. Shape session β LOD_MRM gating fix + CMeshMRMSkinned MRM-growth invariant surfaced through a status flag
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):
-
CMeshMRMSkinnedGeomgainsbool _RuntimeCompiled(default false);compileRunTimesets it true on success, or logs annlwarningwith the counts and clears it on invariant failure. -
CMeshMRMSkinned::isRuntimeCompiled()exposes the flag. - Both exporters (
pipeline_max_export_shapeand the referenceplugin_max/nel_mesh_lib/export_mesh.cpp) check the flag afterbuild(). On false: delete the CMeshMRMSkinned, surface an artist-facing message identifying the node and the fix (setLOD_MRM=0to export as plainCMeshwithSkinWeights, or split the geometry so each part fits), skip the node cleanly. Landed the same catch on both paths β the plugin_max copy usesoutputErrorMessage(Max plugin's user-facing warning channel, same as its other export-time complaints); the headless tool usesstderrwith aSKIPCLASS skinned-maxvertsbucket entry. - On engine load, the same nlwarning fires but no assert β a broken shape file loads without crashing the game, though rendering it via the skin-grouping path would fail cleanly (out of scope for this session; the
_RuntimeCompiledaccessor 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):
- Skinned DIFF quality β as Β§10z-sept. Physique decode remains the biggest lever.
- CSegRemanence (82) β as Β§10z-sept.
- UVW Map (272 uses), Unwrap UVW (38), FFD(box) (20) β as Β§10z-sept. The generator's UVW-Map cases still fail on the Max 9 SP2 gizmo API mismatch (Β§10z-cinq caveat).
-
Cylinder/Sphere parametric UVs;
StdUVGen static matrixβ StdUVGen closed Β§10z-neuf. -
.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):
- Skinned DIFF quality β 653 DIFFs mostly here; Physique cross-link record layout + ConvertToRigid replay (PMD Β§10z-quat / Β§10z-six / MGF Β§M.3) is the biggest lever.
- CSegRemanence (82) + Max SplineShape/Line decode.
- UVW Map (272), Unwrap UVW (38), FFD(box) (20) β modifier tail.
- Cylinder/Sphere parametric UVs (small remaining primitive class after Β§10z-cinq's Box/Plane closure).
-
.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:
- Walks a Shape-superclass scene object's orphaned/claimed chunk tree for the per-spline sibling pattern
0x2900(numKnots) +0x2904(closed) +0x290a(compact knot array) +0x290d. - Compact knot = 52 B:
ktype i32 + ltype i32 + du f32 + Point3 p0 + Point3 p1 + Point3 p2 + flags u32.p0is the knot point matchingShapeObject::InterpPiece3Dendpoints (corpus-verified against every remanence reference corner afterobjectToLocal). Full format write-up: max_geometry_formats Part N. - ClassIds: SplineShape
(0x0a,0), Line(0x1040,0), Rectangle(0x1065,0); SuperClassIdSHAPE=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):
- AppData:
REMANENCE_SLICE_NUMBER(def 2βclamp β₯2),SAMPLING_PERIOD(def 0.02),ROLLUP_RATIO(def 1),SHIFTING_TEXTURE, optional animated-material name. - Single material via
buildMaterials(β 1 β SKIP, same as reference). - Exactly one curve with β₯2 knots; corners = piece endpoints transformed by
objectToLocal = objectTM Β· Inverse(nodeTM)(offset PRS Γ nodeTM, same as water/mesh). - Default transform via
decompMatrix(getLocalMatrix). - Compare path:
compareShapesFieldsgains aCSegRemanenceShapefield 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):
- Skinned DIFF quality β still the bulk of the 1035 DIFF bucket; Physique cross-link + ConvertToRigid (Part M Β§M.3).
-
Residual mesh-eval (14) β shape-class SplineShape/Line/Rectangle without USE_REMANENCE (and a few other non-shape classes); reference also produces no mesh
.shapefor 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. -
UVW Map (272 uses) β shared
UVWMAPlibrary 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). Fixgen_shape_export_dataset.msUVW cases (Max 9 SP2uv.gizmoAPI) first, then un-gate. Unwrap UVW (38), FFD(box) (20) still open. - Cylinder/Sphere parametric UVs.
-
.veget/.clodβ surface completion;.clodreusesPHYSIQUESKIN. - Spline tessellation (shared) β would unblock cmb XRef Shape sources (~35) and any mesh-eval residual that should triangulate; remanence only needed endpoints so left tessellation for a dedicated round.
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. - Bottom cap center = (0.5, 0); top cap center = (0.5, 1).
- Cap fan tri (center, ring[k+1], ring[k]) β the "next" ring vert appears at u_wrap(k+1). Only the fan tri spanning the seam (k where u_wrap(k+1) < u_wrap(k)) needs the shift; the shared per-tri rule handles it automatically without a special case.
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 β seeSPH_TRIiteration). -
v = 1 - i/rowsfor 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 iscos(ΟΒ·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). - Top pole at v=1, bottom pole at v=0.
- Pole-fan tri k: pole corner u = k/segs (the lower-k side of the tri).
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:
- Skinned DIFF quality β bulk of the 1035 DIFF bucket; Physique cross-link + ConvertToRigid (Part M Β§M.3).
- Residual mesh-eval (14) β shape-class SplineShape/Line/Rectangle without USE_REMANENCE.
-
UVW Map (272 uses) β still gated pending GT dataset (Max 9 SP2 gizmo API mismatch in
gen_shape_export_dataset.ms). Unwrap UVW (38), FFD(box) (20) still open. -
Cylinder/Sphere parametric UVsβ DONE this session. -
.veget/.clodβ surface completion. - Spline tessellation (shared) β unblock cmb XRef Shape sources + mesh-eval residuals.
Β§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):
- Node must be a Max light (superclass
0x30). - AppData
NEL3D_APPDATA_LM_ANIMATEDβ 0 andNEL3D_APPDATA_LM_ANIMATED_LIGHT= animation name (empty after clearingSun/GlobalLight/(Use NelLight Modifier)). - Color controller via
getControlerByName(node, "Color")β NeL trackLightmapController.<name>asCTrackKeyFramerBezierRGBA(Bezier Point3 keys, float 0..1, eval-timecopyToValueΓ255). - Source max:
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:
- Shared
TRACKBUILD::buildATrackinpipeline_max_export_common(typePos/Rotation/Scale/Float/Color) β NeLCTrackKeyFramer*key conversions matchingexport_anim.cpp. Color Bezier usesCKeyBezierVectorfloats; Linear color uses(uint8)vallike the reference. -
SHAPEANIM::buildNodeAnimexpands from texmat-only to full single-nodeaddAnimation: 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_shaperuns a separate anim pass over all nodes (not only geometry that produced a shape), matchingfor node in objects do isAnimToBeExported. -
Loop mode provisional rule:
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:0x3002values 24/25 on Point3 vs 0 on PosBezier;0x849954-byte payload) remains open β same class as Β§10b's "ORT bit not located" for the anim-process corpus.
Validation:
- Light groups: 11/11 byte-identical vs
pipeline_export/common/objects/shape_anim+core4_data/objects. - Transform:
conerotor.anim+rotor.animfromfy_cn_taverne_clusterized.maxbyte-identical. - Waterfall texmat: 18/18 still byte-identical on jungle waterfall sample (no regression).
- shape_corpus
--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:
-
Palm-pivot + space gate (
buildPivotSessions): arms accept a session only when|pLocal| > 2 cm(a real palm pivot, not the wrist-pin gesture casepA == wrist) AND the first key's space flagrec[11] == 2(Object/world β the only space in whichrec[18]is an authoritative world wrist). Replaces the oldlimb==0 β pLocal = 0forcing that crippled arms to wrist-pin. -
COM-yaw gate (
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. -
D-window arm restriction (
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). -
Correction gate (
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_ROToff/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:
- FK baseline is already wrong on engarde L Hand (~0.33) because the COM-relative channel is held constant while the reference re-solves the plant.
- With arm IK, correction grows to ~0.37 m (world pin vs drifting FK wrist); the 2-bone solve reaches T (missT ~0) but the world mid-bone rotation can land 0.25β0.48 away from the FK mid (wrong hinge fold on a bent-back L arm).
- Mount-attack wins have the same storage shape but midFlip tops out at β0.15 β the chain reorients coherently.
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):
- Static-plant session drop (arms): 2-knot W channel with end-to-end travel < 1 cm β do not create a pivot session (pure FK). Fixes l2m residual and the fus_idle hand-only regressions; engarde no longer needs the midFlip path for the static case.
- COM-yaw session drop (arms): |yaw(last)βyaw(first)| > 0.7 rad across the session's key span β drop whole session (toupie/demitour/tourne). Replaces the earlier whole-session reject that ran before pLocal validation and also blocked multi-key strikes that only turned a little; now applied after the session is built, against the actual knot span.
- Large-D interval skip (arms, restored): D > 5 cm β FK for that interval (course/strafe locomotion). Keeps the small-D dynamic wins.
- midFlip > 0.18 OR locFlip > 0.40 on accepted dynamic plants (extreme-fold backstop).
- Per-interval hand-rot skip when |ΞCOM yaw| on the covering interval > 0.7 (position solve may still run; hand yaw-frame off).
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):
- ~~Large-D weapon plants (
coup_fortR 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). - In-plant foot rotation (PODA hold rejected; solver-derived floor remains).
- Blend-transition ramps (b_ikb_*).
- Mount-attack residual on L forearm when static plant (FK is the best known; figure/channel mismatch).
- 3-knot large swings (coup2_milieu L arm) and 2-knot large travel (first_pilon) stay FK β dual-fold under-constrained there.
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):
-
Arms interval gate: keep an arm interval when W-channel D is small (genuine small-D plant) or
storedD > 10 cmand 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. - LargePath solve: dual-fold 2-bone (Β±phiT, same local elbow hinge) β both reach T by construction; pick lower midFlip vs FK mid. No midFlip reject on multi-key large-paths (FK mid is already wrong mid-swing; coup_fort t=2560 midFlip 0.94 on both folds while wrist is 30+ cm from FK). Small-D arms keep single-fold + midFlip/locFlip (Β§10s-ter).
-
LargePath hand rotation: keep FK hand (no yaw-frame / Full override). Full world squad was tried and regressed
coup2_milieu; position solve is the accuracy lever.PMB_BIPED_IK_ROT=fullstill forces Full for A/B. - Small-D arm-pin path (Β§10s-quat) unchanged: emote_indifferent, swim_hisser, static-plant drop, COM-yaw session drop.
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.
Β§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:
- Key field
float @ +0x20is the IK weight (f20=1plant,0free) β same role as file-format blend[12]. -
GetIKActive(t)= look-ahead / bracketing: ifnextexists,prev.f20>0 OR next.f20>0; exact key β that key's f20. - Consequence vs pipeline: plantβfree and freeβplant transitions are GetIKActive=1 but must stay channel-FK for export (corpus course heel-strikes are FK-exact on approaches).
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).
- Eval default was byte-identical to Β§10s-cinq on
coup_fort_03(0.511583) until the Β§10s-sept moderate-miss hold. - New:
m_ChPole, planted-only build,PMB_BIPED_IK_POLE=1experimental twist. - Docs: limb header /
biped_anim.hidentify[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:
- SWIVEL β 0.003 rad (peak |swivel| 0.0025). The pole/swivel is ALREADY EXACT. Β§10s-six was right to leave the pole twist off β twisting moves AWAY from the correct swivel (which is why POLE=1 regressed 0.51β0.64).
- Pointing tilt β 0.42 rad (peak). The elbow placement diverges β our upper-arm points 0.42 rad off Max's.
- Elbow offset (β₯ reach axis): ours 0.07β0.13 m, Max β 0.002 m. Max's elbow sits ON the shoulderβwrist line; ours is bent off it. Max's arm is nearly STRAIGHT (extended); ours is bent.
- Radial reach: our solve target T dips to 0.427 m; Max's wrist holds 0.499 m (CONSTANT across the plant). Max's arm stays EXTENDED at a near-constant reach; our target shrinks the reach, so law-of-cosines bends the elbow.
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:
- Biped direct/structural tier: byte-identical to the x64/SSE build. 7 byte-identical + 3166 structural (595 over the 0.25 tol), worst-delta median 0.06886, max 1.279 β matching the x64 numbers to the digit. The biped oversampling eval is float-codegen-independent (the per-frame keys are a deterministic resample; the IK in-between error is a model gap, not SSE-vs-x87 double-rounding). So the VS2008/x87 reference codegen does not close any of the biped IK error β confirming the Β§10s/Β§10s-bis framing that the IK accuracy lever is the solve model, not precision.
- Non-biped direct tier: 10/10 byte-identical under both builds (the plain keyframer path doesn't accumulate enough FP ops to diverge).
-
Non-biped optimized (fauna) tier: 7 files over the reconstructed-animation tolerance under VS2008 (x64: 0;
1+60/61EPS both). Exactly Β§10m's "the fauna optimized tier is genuinely cross-build-fragile, and that's a property ofanim_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.OutTanwas read at the wrong offset (floats [10..12] β always zero). The real 0x2528 layout afterS[3]+Q[4]is four 7-float blocks at stride 7 β{InTan, OutTan, InLen, OutLen}, eachvec3 + 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 againstpr_mo_phytopsy_attack's referenceBox31/Box32.scaletracks). Roundtrip was never affected (raw bytes authoritative); only the export read view moved. -
addBoneTracksnever re-entered the biped path on a nested foreign COM. The reference'saddBoneTracksre-entersaddBipedNodeTrackson any BIPBODY child, with the ONECAnimationBuildCtxbuilt over the whole selected subtree up front β so the mektoub_selle rider rig (Bip01maleunderselle-assise/selleinside the mount's subtree, its bones confusingly still namedBip01 *) 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/4chains,Ponytail2, its end-effector dummies,Bip01maleitself) surfaced as the 2 structural fails.addAnimationnow builds the sampled context once per selection (like the reference) andaddBoneTracksre-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. -
A nested/linked biped COM exports a CONSTANT rigid local TM = its figure-mode attach β the kitin family's Bip02 (abdomen rig linked under Bip01 Spine) rode our channel-world anchoring up to 5.5 m away from its running mount, because a linked COM's h/v/t channels do NOT place it in world: Character Studio carries the whole linked rig with the parent. The reference's
Bip02.pos/rotquatare 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. -
Non-biped direct verdicts gain a FLOATEQ tier (strict 5e-7). 46 of the 71 non-biped files differ from their direct refs by 1-2 ULP in Bezier/Linear scale values only β the
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):
- The LIVE local TM of every linked COM matches its shipped reference exactly, to the last digit, rotation included β kitin_run, the stun trilogy, kitin_queen, and the mektoub rider. Nothing was ever stale; every residual was ours.
- Max's TRUE figure-mode attach (toggling figureMode and re-reading) is a different value than the ridden local on every file β so the attach is NOT figure-derived at all; Β§10m-ter's "figure attach" reconstruction only coincidentally matched on files whose saved state sat near figure, and its 6e-3 "rotation residual" was the coincidence degrading.
-
biped.getTransformβ‘node.transformon a linked COM (no channel-vs-node split) β the ridden TM is the only live value. - The live local is bit-constant across every file's whole range.
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.
10z-douze. Shape coverage audit β spline caps, UVW planar, PB2 prims, Physique cross-links; not-produced 13 β 1
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:
-
9 spline-cap/empty shapes (
shape01..07,rectangle02in common/characters fromma_hom_armor04.max/ma_hof_armor04.max;rectangle01+tr_wea_hache2m_trail_00in common/sfx frommag_impact_cold.max/tr_wea_hache2m_trail.max;fy_hom_interfaces_newalso 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 is0x290d, not0x2904(open trails 0, closed outlines 1; du = 1/n closed vs 1/(nβ1) open corroborates), and0x1050= 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 sharedpipeline_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, fromfy_cn_smokehouse.max) β a CWaterShape whose surface node is a Plane, a Max 4+ class storing a ParamBlock2 instead of the old-style pblockextractParametricPrimitiveexpected (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. Apipeline_max_veget_corpusctest 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_vs2008wired (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%.
10z-treize. Skinned world-space + the Physique link-parent rule β skinned exports become position/weight-exact
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):
- ~550 CMeshMRMSkinned structural β MRM rebuilt from float-noise-divergent world-space inputs takes different simplification decisions (vertex counts differ by 1β5 %, lod index sets differ). The analog of the anim optimizer tier: the 122 byte-identical files prove the pipeline is exact when inputs are bit-equal; the rest is the reference build's x87 arithmetic upstream of a threshold-heavy builder. A candidate residual lever: the used-bone-order tie (ours [Pelvis, R Thigh, Spineβ¦] vs ref [Pelvis, Spine, R Thighβ¦] on equal-weight ties) feeds the MRM's matrix grouping β not chased this session.
-
~75 interface-weld normals (
*_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. - x45 weapons class (fusil/lanceroquette: dscale+norm+pos+quat) β the Β§10i multi-smoothing-group normals residual + transform noise.
- x14 CWaterShape poly (watermarais class β Β§10z-bis known: polygon source differs through mesh eval).
- x10 CMeshMRMSkinned-vs-CMeshMRM class mismatch (visage files) β the reference exports these as plain CMeshMRM because its Morpher blend-shape list is non-empty (bsList β the CMeshMRM branch); our morph targets are unimplemented. The M3 Morpher item, now with a measurable file list.
- Small tails: idx/vbcount CMesh classes (Unwrap UVW 38 uses, FFD 20, EditablePoly map channels), CMeshMultiLod matcount (slave-material merging β mostly inside the lightmap buckets).
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.
10z-quinze. Interface weld + mapext verification tier + sky-dome hemisphere + created-vert clone decode + decompAffine revert
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 (buildMeshInterfacegains an optionalmorphFinalSpaceright-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):
- Interface-weld normals residual (~75 files down to ~0.002β0.2 on border corners; biped-box candidate gap).
-
CMeshMultiLod slave-material merge (2 mapext files + the Β§10y
materials: 3 vs 4lightmap-tier class) and zo_hom_acc_gauntlet rdrpass-count. - MRM-structural class (~550, Β§10z-treize) and the rest of Β§10z-treize's handoff unchanged.
- Plain-CMesh Morpher path (buildMeshMorph β setBlendShapes) β not implemented; no corpus file proven to need it (the visage class is MRM).
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.
-
Modifier level: the original stores nothing of its own. Pinned this session: the corpus-wide
0x39bf{0x0100 empty}object stream is exactly what the Max SDK's ownModifier::Savewrites β verified by dumping a corpusnel_vertex_tree_paintobject (our plugin whose Save source we have: its stream is the same 0x39bf pair followed by its own version chunk). So the original'sSavewas literallyModifier::Save(isave)alone, and the replacement does the same β byte-compatible saves for free. - LocalModData: raw-preserving chunk-tree load/save. Typed functional set (0x03e8 nVerts / 0x03e9 UVW / 0x03ea nFaces / 0x03eb corners / 0x03f3 channel); everything else β the secondary channel snapshot (0x03f0/0x03f1/0x03f2), per-face words (0x03ec) and flags+matID dwords (0x03f5), the selection-ish smalls (0x03ed/0x03ee/0x03ef/0x03f8), the 0x03f9/0x03fa sub-containers and 0x044c = {3100, 198} (a version stamp matching "mapext198") β is carried verbatim, so a re-save from Max drops nothing. (Part P's chunk table now covers all 17 ids.)
-
ModifyObject replays the cache onto the mesh's map channel:
setMapSupport+ full map-vert/map-face replace, refuse on face-count mismatch β the corpus-proven semantics of the headlessMAPEXT::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. -
Validation channel β RUN COMPLETE, 118/118 (2026-07-10 evening): the Max 2010 run landed (
~/mapext_probe/manifest.txt, all 118 nodes