Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Research reportsForschungsberichte

Mesh Format (model / modelAsset / modelstream): Resolved, Box is writable

SolvedGelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

This report decodes how Wolfenstein II: The New Colossus stores 3D models. Models hold their geometry uncompressed, with up to three detail levels and a fixed 48-byte layout per vertex. The reader and writer reproduce all 22,400 retail models byte-for-byte, and a simple box mesh was built to replace a metal crate. The box passes all offline checks, but it has not been seen in the running game.

  • 22,400 of 22,400 models across all 27 retail archives parse and rewrite byte-for-byte, plus 7,465 of 7,465 vertex paint streams.
  • All models use one vertex layout of 48 bytes; indices are 16-bit, and up to 3 detail levels are allowed.
  • 'modelstream' entries are per-instance vertex paint, not geometry; 'modelAsset' is only a text description.
  • A new model needs a correctly formed name, an existing material and an entity that places it; collision is stored separately.
  • In this run, an error in the box's material list from an earlier run was found and fixed.

Dieser Bericht entschlüsselt, wie Wolfenstein II: The New Colossus 3D-Modelle speichert. Modelle enthalten ihre Geometrie unkomprimiert, mit bis zu drei Detailstufen und einem festen 48-Byte-Aufbau pro Eckpunkt. Leser und Schreiber reproduzieren alle 22.400 Modelle des Spiels byte-genau, und als Ersatz für eine Metallkiste wurde ein einfaches Box-Mesh gebaut. Die Box besteht alle Offline-Prüfungen, wurde im laufenden Spiel aber noch nicht gesehen.

  • 22.400 von 22.400 Modellen aus allen 27 Archiven des Spiels werden gelesen und byte-genau neu geschrieben, dazu 7.465 von 7.465 Vertex-Paint-Streams.
  • Alle Modelle nutzen einen Eckpunkt-Aufbau mit 48 Byte; Indizes sind 16 Bit groß, erlaubt sind bis zu 3 Detailstufen.
  • „modelstream“-Einträge sind Vertex-Bemalung pro Instanz, keine Geometrie; „modelAsset“ ist nur eine Textbeschreibung.
  • Ein neues Modell braucht einen korrekt gebildeten Namen, ein vorhandenes Material und ein Objekt, das es platziert; die Kollision liegt getrennt.
  • In diesem Durchlauf wurde ein Fehler in der Materialliste der Box aus einem früheren Durchlauf gefunden und behoben.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

Worker w-meshfmt-a2, third run (a1 paused by user, run 2 truncated after session limit report), 2026-09-23. Tool: tools/probe_meshfmt.py, Outputs: re_probes/meshfmt/*.txt.

Results

  • model contains geometry fully and uncompressed: 3 LOD slots, float32 positions, 48-byte vertices, u16 indices. There is no GPU-specific format and no streamed LOD tails.
  • Parser and Writer are byte-exact: 22,400 / 22,400 models across all 27 Retail Archives (24 × base, dlc_1..3), plus 7,465 / 7,465 modelstream. For the sample from the task (3 archives), it is 1,610 / 1,610.
  • New geometry is writable. The box is located in re_probes/meshfmt/box_chunk_8.resources (sha256 221a3e00…792fd325, deterministic). Container hashes match: 1562/1562, +0x08/+0x20 OK.
  • Structurally valid after these checks, NOT tested at runtime.

What this run corrected compared to the report from Run 2

All commands were re-executed. Parser, Round-Trip, Negative Controls, and Naming Rules held with the same numbers. Two points were incorrect:

  1. The box had an incorrect material list entry. It wrote (material, 0, 0, 3), i.e., a "Retail majority value". The three u32s are derived: (Surface-Index, smallest index, largest index of LOD 0). Surface areas tile their LOD-0 index range seamlessly, starting from about 32,766 vertices as two ranges. This applies to 20,623 / 20,624 Retail models with a list. The exception is a Skydome whose surface carries missing_material. Correct for the box is therefore (material, 0, 0, 23). The old box would have reported the vertex range 0..3 instead of 0..23 to the engine. Fixed. check_geometry() now checks this rule, and the old box serves as its own negative control. Consequently, the container has a new sha256 (old 3389a0cf…, new 221a3e00…).
  2. "The box takes Retail majority values" was not accurate. Re-measured:
    • head (12 B → model+0x74) is an f32[3]. In 22,214 / 22,400 models it is (0, ±0, 0). The box writes zeros.
    • head2 (12 B → model+0xB0) is null exactly when all surfaces have only LOD 0: 22,335 / 22,344. These are LOD parameters. The box has only LOD 0 and therefore writes zeros, which complies with the rule.
    • The Surface hash is never 0 in Retail, nor is it the crc32 or fnv1a of the material name. The loader reads it at [rbp-0x7C] and never reads it again: throughout the entire loader there are exactly 2 accesses, the zeroing and the lea for the read. The box writes 0, and the loader ignores the value.
    • density = 1e30 (0x7149F2CA) at both values causes the engine to calculate density itself (0x82B08A compares, 0x82B0A4 sets). Only 4 of the 4,069 LOD-0 in the sample use this. The box uses it.

New additions: --loader checks 21 code facts, 3 .pdata boundaries, and the non-use of the hash directly in module.bin (25/25). --box-into now rejects Megatexture models and vertex-painted models instead of writing a broken container. The docstring sentence "Retail passes all checks" was false: only 569 / 1,610 Retail models pass the Authoring Checks. All failures concern counter-clockwise winding triangles or normals/tangents deviating by more than 0.02. The docstring has been corrected.

Loader (module.bin, Code-RE, --loader checks it afterwards)

Function .text (.pdata) Evidence
idStaticModel::LoadFromBinary 0x59BEC0–0x59D0E9 "bad numSurfaces" 0x59C07E, cmp [rsp+0x64],0x724c4d42 0x59C754, "Bad trailing magic" 0x59CF0A
idTriangles::ReadFromFile 0x82ABE0–0x82B0E6 bad numVerts / numIndexes / vertexMask, each FatalError + int3; cmp eax,0x30 0x82AF31
Vertex size 0x51F290 (Leaf) Bitfield read, 0x1801F → 48
Static-Model-Stream-Loader 0x5A0360–0x5A08D9 staticmodelstream.cpp

The complete byte layout with addresses is documented in the Docstring of tools/probe_meshfmt.py. Vertex 0x0001801F (the only format in 174,860 / 174,860 LODs): pos f32×3 | uv0 f32×2 | normal u8×4 | tangent u8×4 | colour u8×4 | uv1 f32×2 | u16×4. bounds == min/max of positions in 174,860 / 174,860 LODs. Triangles run clockwise against the normal (chunk_1: 1 224 382 cw to 532 ccw).

Engine Limits (otherwise FatalError or memory corruption; the parser rejects these cases): Stride ≠ 48; numLods > 3 (LOD pointers are at +0/+8/+0x10, +0x18 is a different field, 0x59CB07); missing LOD 0 (LOD 0 creates the surface, 0x59C7EB → 0x59C808); more than 7 Extra-Strings (Stack Array 7 × 0x130, 0x59C233/0x59C238); u16 indices. Rest bytes and short reads are not checked by the engine. The parser is intentionally stricter here.

Question 1: how model, modelAsset, and modelstream relate

  • model is the geometry. …lwo$static_instance=1$lodgroup=g is a placed mesh, maps/…/_combo/*.bmodel is a brush model. _world.bmodel has 0 surfaces, so there is no world blob.
  • modelAsset is a text declaration (renderLODs, collisionSetup), so only a description.
  • modelstream is vertex paint per instance and contains no geometry: a u16 per vertex and LOD. count == numVerts of the paired model in 6,135 / 6,135 pairs, byte-exact 7,465 / 7,465.
  • lodGroup (17 Decls) provides lods.num = 3 and path prefixes for the resource name.

Question 2: the Writer

python tools/probe_meshfmt.py --box-into SRC "NAME" OUT

The command replaces model NAME in SRC with a box that fills the LOD-0 bounds and adopts the material. Material list membership comes from the original. Then idclwrite.write() and finalize() run, and the result is read back. Example: chunk_8, models/crates/metal_crate_02_70cm_100cm.lwo$static_instance=1$lodgroup=default → Box −0.531..0.529 × −0.53..0.533 × 0..0.692, 1,568 bytes, 24 V / 36 I, 12/12 cw. Only 1 data hash changes. The library function for custom meshes is build_box(material, lo, hi, listed=True) with write_model()/parse_model()/check_geometry().

Question 3: what a mesh additionally needs

  1. A model entry under the name formed by the engine: lower(model) + "$static_instance=1$lodgroup=" + g. g is the lodGroup with the longest path prefix, otherwise default. The rule applies to 19,205 / 19,205 Retail names; the "all default" check only covers 7,500.
  2. A material that exists by name. If missing, there is a warning and the Default material (0x59C854). See w-matpipe.
  3. A placement via an Entity with renderModelInfo.model (w-entitypipe).
  4. Not required: modelAsset, modelstream, LOD 1/2 (in Retail they are absent in 9,794 / 64,815 surfaces), density (1e30 → the engine calculates it).
  5. Collision is separate (cm, w-cmfmt). Upon replacement, the old form remains.

Checks (executed in this run, output in re_probes/meshfmt/)

Command Result
--loader 25/25 ok, exit 0
--selftest Box 1 499 B, 24 V / 36 I, 12/12 cw, List (…,0,0,23), exit 0
--verify 1 610/1 610 parsed and identical, bounds 10 645/10 645, Streams 252/252, Pairs 176/176, List 1 374/1 374
--verify --all 22 400/22 400, bounds 174 860/174 860, Streams 7 465/7 465, Pairs 6 135/6 135, List 20 623/20 624, head2 22 335/22 344
--negative 12 Mutations × 60 Retail Models: 0 accepted. Round-Trip Control 0/60 identical. Box from a1 (wrap) and Box from Run 2 (List) are rejected
--conventions / --names cw 1 224 382 : ccw 532 / 19 205 : 19 205, Control 7 500
probe_datahash / probe_idclhash on box_chunk_8 1562/1562 / +08 OK, +20 OK
Same container without finalize() 1561/1562, +0x20 BAD, each exit 1
--box-into on Megatexture or Vertex-Paint Model rejected, exit 1, no file

Open / Risks

  • Runtime unproven. Offline proof does not demonstrate that the engine renders the Box.
  • The consumer of the material list (model+0xC0) has not been found. The rule is measured (20 623 / 20 624) and not read from code. Similarly open is the semantics of the head/head2 floats, from unk_28, unk_a/b (Retail 0xFFFFFFFF/0 in 4 062 / 4 069) and the Normal byte 3.
  • Megatexture materials (material+0x650 ≠ -1, Patch-Table at 0x59BC90) are not decoded. --box-into rejects them.