Skip to content

Research Report R4 Model Import

LopeKinz edited this page Oct 1, 2026 · 1 revision

R4 Research Report: Importing OBJ/GLB Models into TNC

Builds on the existing mesh-format, collision-format and manifest research. New tooling: a standalone OBJ/glTF importer that reuses the project's own, unmodified model/collision/manifest writers rather than re-deriving any of those formats.

Summary

The question was whether an artist-made OBJ or GLB file can be converted into TNC's static model + cm (collision) format, and whether a skeletal md6mesh writer is feasible, well enough for the game's own parsers to accept the result. A new importer turns a hand-authored cube and a real 3,543-vertex retail crate (exported through the project's own existing exporter first, then re-imported) into model/cm payloads that round-trip byte-identically through the project's parsers, pass the geometry checker with zero problems, and — written into a real copy of a game archive with correct hashes — pass the project's independent container-integrity checkers. A new md6mesh writer round-trips all 450 decompilable retail skeletal meshes byte-identically and also accepts a brand-new, hand-authored unskinned mesh through the project's own, unmodified reader. Everything here is static: format, round-trip and container-hash checks only — the game was never launched, so none of it is confirmed to render or collide correctly on screen. One concrete new fact about the engine's own content pipeline was found in the process: it re-corrects vertex normals on static models too, not only on skinned ones, contrary to the existing tooling's own documentation.

1. The model (static mesh) writer

Nothing about the container-level model format itself is new — that was already solved byte-exact over 22,400 retail models. What's new is everything upstream: a real OBJ parser (v/vt/vn, usemtl per material, triangle fans for n-gons), a real glTF/GLB reader (interleaved or non-interleaved accessors, any index width, sparse accessors explicitly rejected rather than silently misread), normal generation for files that don't carry one (reusing the project's existing area-weighted normal function, not reimplemented), and per-vertex tangent generation (a simple accumulate-and-orthogonalize scheme — marked as a known simplification: good enough to pass every check run against it, including the real retail crate, but not full MikkTSpace; upgrade if a mirrored-UV model ever shows a visibly wrong tangent handedness).

Cube proof: an independently hand-authored OBJ cube round-trips through parse→build→write→reparse byte-identically (1,499 bytes), passes the geometry checker with 0 problems, and — written in place of a real metal-crate prop inside a 27.8 MB copy of a real game archive — passes the project's own independent hash checkers (entry-level data hash and the IDCL container header hash) with zero deviations elsewhere in the 1,562-entry archive.

Re-encoded retail prop: the same crate, exported through the project's own existing exporter to both OBJ and GLB, then re-imported here, reproduces the original retail vertex positions and UVs exactly (bit-identical for GLB, float-text-rounding only for OBJ) and the normals exactly — once compared against the correct baseline (see §1.1). The only field that doesn't round-trip to the original bytes is the tangent, which is expected: no interchange format carries a tangent channel, every importer regenerates it.

1.1 New finding: the content pipeline fixes normals on static models too

The first comparison attempt showed a normal mismatch on 136 of the crate's 3,543 vertices (3.8%). Running the project's existing fix_normals tool — documented as a skinned-model tool only — directly on the crate's raw retail data zeroes exactly those same 136 vertices, and the exporter's normal output matches bit-for-bit afterward. None of the 136 were near-zero (the condition the plain normal function alone checks); they are normals that disagree with their own triangle, which only fix_normals catches. This is an inference from an exact reproduction, not a read of the actual content-pipeline source: whatever builds static model geometry evidently runs the same normal-correction pass that was previously thought skinned-mesh-only.

2. The cm collision box

Nothing new in the format: the collision writer is a thin wrapper around the already-proven (also runtime-untested) box builder, fed the imported mesh's own bounding box. Both the cube and the re-encoded crate produce a clean collision hull (0 problems: corners, planes, winding, connectivity, mass, penetration) and round-trip byte-identically through the same container replace.

3. md6mesh: feasible, with one correction

The existing md6 parser silently discards two opaque per-mesh data blocks right after the morph list (sized 8*n/16*n bytes). Measured across all 450 decompilable retail skeletal meshes this install has: 396 of 5,188 meshes, in 99 of 450 files, have non-empty blocks, up to 3.04 MB in one mesh — plausibly per-morph vertex deltas (not decoded, out of scope here). Practical consequence: any tool built only on the existing parser cannot losslessly rewrite 22% of retail skeletal-mesh files.

A new full-fidelity shadow parser/writer keeps those two blocks as opaque bytes instead of discarding them and round-trips 450/450 of the retail corpus byte-identically (negative controls: truncated-by-4 and padded-by-4 both correctly raise). A brand-new, OBJ-authored, unskinned, bind-pose single mesh built with this writer parses correctly through the project's existing, unmodified reader and reproduces the input geometry exactly. Scope: bind pose only — joint weights, multiple LODs and morphs are not attempted, matching the existing exporter's own stated limitation.

4. What a map/entity needs to place a new model

A new resource needs one row in the map's resource manifest (author tool reused unmodified, all of its own internal consistency checks pass for a brand-new name) and one idStaticInstanceEntity block in the map's entity text (built with the project's existing kit-map helper, unmodified). The generated entity block was cross-checked field-for-field against a real retail entity placing this exact crate, found by searching a real campaign map — identical shape, confirming the reused helper produces retail's own form, not just a plausible-looking one.

The one remaining, inherited gap: the project's archive writer only ever replaces the payload of an entry already in a container's table — it cannot add a brand-new (type, name) entry to an existing archive. A same-name replacement (§1) is therefore container-provable today; a brand-new name's manifest row and entity text are provable today (this section); but depositing that new name's actual bytes into a container is not, until a table-growing writer exists. This pass added one concrete fact for whoever builds it: growing the table is fully specified (every affected header field and region is already documented), not an unknown format — it just hasn't been written.

Open questions

  • Runtime behavior of all of the above — nothing was run in game.
  • The two opaque md6 per-mesh blocks' internal layout (located and sized, not decoded).
  • Whether the model format's density=UNSET/3-LOD convention this writer carries over to md6mesh is actually meaningful there, or just tolerated.
  • A table-growing IDCL writer (the append gap, §4) — fully specified, not yet built.

In-game test (for the tester)

This test temporarily replaces a base-game archive (not a DLC folder) with a copy where one metal crate is swapped for a plain gray cube of the same size.

  1. Back up and install (game closed): copy <game folder>/base/chunk_8.resources to a .bak, then copy the generated cube_chunk_8.resources over the original.
  2. Start the game and go to Chapter 4, second part (map c04p2), the "cooling" area, roughly position x=-7.6, y=-86.5, z=-8.1 — fastest via a save already in that chapter.
  3. Look: the small metal crate (about 70x100 cm) should now be a plain gray cube of the same size, with solid collision. If it still looks like the normal crate, the replacement file was not loaded (check the path). A crash on map load: proceed straight to step 4.
  4. Restore (game closed): copy the .bak back over chunk_8.resources.

Clone this wiki locally