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-05T00: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 compound document, accessed through libgsf (gsf_infile_msole_*/gsf_outfile_msole_*). NeL'sNLMISC::IStreamis bridged onto gsf streams byCStorageStream(storage_stream.h/.cpp) β a thin seek/read/write adapter. - 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 (gsf_input_uncompressis noted in a comment in storage_stream.h) and are not handled. If the corpus contains any, they must be detected and either supported or explicitly skipped with a count β not silently failed. -
derived_object.*,wsm_derived_object.*,storage_file.*are empty stubs (constructor/destructor only). Placeholders, no design in them.
An OLE compound document with these streams, all of which must be preserved on rewrite (this is exactly the set pipeline_max_rewrite_assets reads and writes back):
| Stream | Handling today |
|---|---|
Scene |
CScene β the real content; one version-numbered root chunk containing the scene class container |
DllDirectory |
CDllDirectory β fully parsed (list of plugin DLL description/filename pairs) |
ClassDirectory3 |
CClassDirectory3 β fully parsed (per-class: dll index, ClassId, SuperClassId, display name) |
ClassData |
CClassData β container structure known (entries 0x2100, header 0x2110 with ClassId/SuperClassId); entry body 0x2120 depends on the header and is an open TODO, kept raw |
Config |
CConfig β partially typed (0x20a0 entry table, 0x2180 ConfigScript with its recursive "meta container" value format); many leaf ids known only as unlabeled ints/strings |
VideoPostQueue |
generic CStorageContainer (raw pass-through) |
\05SummaryInformation, \05DocumentSummaryInformation
|
raw byte blobs, copied verbatim |
| OLE class id (16 bytes) | read via gsf_infile_msole_get_class_id, must be written back via gsf_outfile_msole_set_class_id
|
The roundtrip unit is the stream, not the file. libgsf will not reproduce Max's 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).
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); it sets a stickym_Is64Biton the stream. The writer refuses to write whenm_Is64Bitis set (enterChunkthrows). Consequence, codified: a file that contains any 64-bit chunk header can be read but not currently rewritten. The corpus run must count these; if any exist, 64-bit write support (with a "write 64-bit iff read 64-bit" rule to preserve byte-identity) becomes a prerequisite. - 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 β build β disown β serial(out) parsed-but-unmodified, 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::createChunkByIdas unknown-class instantiations rather than directory lookups. -
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. A new typed class = write the class + add oneregistry->addline; nothing else changes. Plugin-DLL grouping is mirrored in namespaces (BUILTIN,UPDATE1,EPOLY) with aIDllPluginDescper DLL (update1.dlo,EPoly.dlo); 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). -
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)
β ββ CRootNode β
ββ CTrackViewNode β (0x0110/0x0120/0x0130 per child, empties 0x0140/0x0150)
ββ CBaseObject β (no own chunks)
β ββ CObject β CGeomObject β (0x08fe GeomBuffers, 0x0900)
β ββ CTriObject β (ids 0x0901-0x0904 noted, not yet claimed)
β β ββ UPDATE1::CEditableMesh β (known-unknown sequence kept in order)
β ββ CPolyObject β (ids 0x0906-0x090c noted, not yet claimed)
β β ββ EPOLY::CEditablePoly β (known-unknown sequence kept in order)
β ββ CPatchObject β CEditablePatch (β registered, pass-through bodies)
ββ CModifier, CMtlBase, CMtl, CStdMat, CStdMat2, CMultiMtl,
β CTexmap, CBitmapTex, CParamBlock, CParamBlock2 β stubs (empty, unregistered)
ββ (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), but the idβtype mapping is compiled out:PMBS_GEOM_BUFFERS_PARSEis 0 (geom_buffers.cpp:52), so geometry currently rides through as raw. Enabling it is the first real corpus test of the typed leaf serializers.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). -
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_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: green on the 9 non-biped skel .max files across DllDirectory / ClassDirectory3 / Scene streams, including 64-bit chunk round-trip. -
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 DllDirectory and ClassDirectory3 across the 9 non-biped skel files; T2 Scene has size-match but ~3200 small internal diff runs per file β remaining issues are typed-class-level (somecreateChunkByIdmappings still mishandle container-flag round-tripping; systemic sweep needed). -
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). 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 182 files: 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. Remaining 167 biped skeleton files need biped-controller reverse-engineering (BipBody 0x9156 / BipSlave 0x9154 storage is proprietary) β separate effort.
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.
11. Known defects and hazards (verified in source; fix or guard before building on the affected path)
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.
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).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. Verified on the two sample skel .max files (Version9 Scene streams that are 100% 64-bit chunks) β T1 now byte-identical. -
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.
-
Corpus-gate every change. T1 green before starting; T2 green over all ~8.6k files after each newly typed chunk, class, or enabled
#define. No batch of typing lands on a red corpus. - Type only what you can prove. A chunk id gets a typed mapping only when the typed serializer reproduces every instance in the corpus byte-exactly (that's what T2 verifies). No guessing formats from the Max SDK docs without corpus confirmation; unknown ids keep falling through to raw/container defaults.
-
Never bypass the lifecycle. New classes implement the full sextet (
parse/clean/build/disown/init/toStringLocal+createChunkById), call the parent first (parse/build) or last (disown), guard onm_ChunksOwnsPointers, usegetChunk/putChunk/m_ArchivedChunksexactly as Β§5 describes, and put chunks back in the order they were taken.CEditableMeshis the template for "typed class that mostly passes through";CNodeImplfor "small fully-typed class";CAppDatafor "container with its own entry bookkeeping";CDllDirectory/CClassDirectory3for "entry table with position cache" β start from the nearest template, don't invent a fifth shape. - Preserve emission forms. Where the format has alternatives (0x2034 vs 0x2035 references, 32- vs 64-bit headers, present-vs-absent optional chunks), record which form was read and re-emit that form. Byte-identity is the spec; "semantically equivalent" is not.
-
First milestones, in dependency order: (a) defect list items 1-2 done β see Β§11 "Fixed"; (b) build the corpus harness out of the dump tool (argv, per-stream compare, per-file isolation, version bucketing); (c) run T1/T2 baseline, inventory unparsed-class and 64-bit/compressed counts; (d) enable
PMBS_GEOM_BUFFERS_PARSEand re-gate; (e) claim the noted tri/poly object ids (0x0901-0x0904, 0x0906-0x090c); (f)PARSE_NELDATA: typed AppData decoders perexport_appdata.h; (g) materials/param blocks (fill the stubs, register them) as export needs them; (h) node transform controllers (currently unknowns β needed for object placement at export); then thenel_mesh_libexport logic retargeted onto this object model, T3-gated. The lightmapping phase is explicitly out of the exporter: shapes export unmapped, andcalc_lmbecomes a separate standalone tool validated in aggregate (see T3 caveats). - Keep this document current. New chunk ids, new normalizations, new invariants, fixed defects β same commit that changes the code updates this page and the Tutorial Tech Tree status.