From 3cb575f799bcd43bce3fbeb3da00cc85d8e6e647 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Tue, 8 Sep 2026 16:28:25 -0400 Subject: [PATCH 01/14] Document the .md2 model format - static meshes and both known animation track kinds Written from OpenTPW's verified implementation (source/OpenTPW.Files/Formats/Model), not guessed - every offset here has been checked across the game's full model data, and every "unknown" is marked as one rather than filled in. Covers: - The header fields both mesh and animation files share, and the mesh-table-offset discriminator between the two kinds. - Static meshes: the 160-byte mesh table record, the batched-by-4 axis-major vertex layout, the vertex order table's role in grouping vertices by material, faces, and the two parallel texture-name tables. - Animation files: the vertex (morph) track table (282 files, 22%) and the rotation track table (686 files, 54%), including the target-index caveat for models with extra hierarchy nodes. Notes that ~24% of animation files match neither table and what a reader should do about that (skip, don't guess). No models doc existed in this repo before now. --- src/content/docs/formats/models.md | 389 +++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 src/content/docs/formats/models.md diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md new file mode 100644 index 0000000..dc8f10a --- /dev/null +++ b/src/content/docs/formats/models.md @@ -0,0 +1,389 @@ +--- +title: Models (*.md2) +--- + +MD2 files store 3D models: static mesh geometry (rides, terrain, props), and separately, the +keyframe animations that pose a model's meshes over time (a gate's doors swinging, a dinosaur's +head turning). Both kinds share one file format and the same `.md2` extension - which kind a +given file is has to be determined from its contents, not its name. + +An animation file sits beside the mesh it animates, with an `M1`, `M2`, ... suffix on the base +model's filename - `droid.MD2` is a mesh, `droidM1.MD2`/`droidM2.MD2` are animations of it. This +holds for 1274 of the 1279 animation files in the game; the five exceptions are `SCALE.MD2`, +`ROTATE.MD2`, `FLY.MD2`, `anim.MD2` and `scatM1.md2`, which don't sit next to an identifiable +base model. + +Nothing in the format says how a model's animations are sequenced or when they should play - in +particular, a gate's "doors open" and "doors close" animations are just two more `M`-suffixed +files, indistinguishable from each other by anything in the `.md2` data. In the original game +this is almost certainly driven externally, by a `TRIGANIM` instruction in that ride's +[compiled script](/formats/rsse) - see the `.closed`/gate example on the +[RSS](/formats/rss) page, which triggers an animation by index in response to game state. + +> This page reflects an ongoing reverse-engineering effort - see **Open questions** at the end +> of each section for what isn't nailed down yet. Every offset and rule stated as fact here has +> been checked against the game's full model data (over 2,300 files), not inferred from one or +> two examples. + +## Shared header + +Every `.md2` file, mesh or animation, opens with the same header shape. Most of it is still +unidentified - the table below lists only the fields this project's parser actually depends on; +everything else is a gap of unknown content, not a claim that nothing is there. + +| Offset | Size | Description | +| ------ | -------- | ------------------------------------------------------------------------------------- | +| 0x00 | 4 bytes | Magic number - `46 5D D1 1C` (little-endian `0x1CD15D46`) | +| 0x04 | 4 bytes | Constant - always `0xDD` | +| 0x08 | 4 bytes | Constant - always `0xCB` | +| 0x0C | 4 bytes | Unknown - varies per file | +| 0x36 | 2 bytes | Frame/texture count | +| 0x44 | 2 bytes | Mesh count | +| 0x50 | 4 bytes | Frame table offset (see **Textures**, below) | +| 0x54 | 4 bytes | Frame data table offset (see **Textures**, below) | +| 0x70 | 4 bytes | Mesh table offset - **0 marks this file as animation data**, see **Animation** below | +| 0x80 | - | Start of the model's overall bounding box (not yet parsed - see Open questions) | +| 0x98 | 4 bytes | Animation data block offset - animation files only, see **Animation** below | + +Everything from 0x10 to 0x36, 0x38 to 0x44, and 0x46 to 0x50 is an unidentified gap. There is +almost certainly more structure in 0x80 onward that this project doesn't yet read for static +meshes either. + +### Open questions + +- The meaning of the 0x0C field, and everything in the unidentified gaps above. +- The exact shape of the bounding box at 0x80 (min/max as two vectors, one vector plus extents, + etc.) - its presence is inferred only from animation files never containing a float triple + that reproduces it, not from having parsed it directly. + +## Static meshes + +A static mesh file has a non-zero mesh table offset at 0x70. Everything the mesh needs - +vertices, UVs, faces, materials, texture names - is reachable from the header fields above plus +the per-mesh table this section describes. + +### Textures + +The frame table (offset at 0x50) is an array of *frame count* (0x36) 8-byte entries, immediately +followed by that many 20-byte null-padded ASCII texture filenames - one string per entry, in the +same order. A material's `FrameOffset` (see **Materials** below) is a byte offset that lands +inside this 8-byte-entry array; dividing `(FrameOffset - frameTableOffset)` by 8 gives that +material's index into it. + +The frame data table (offset at 0x54) is a second, parallel array of *frame count* 16-byte +records, indexed the same way: + +| Size | Description | +| ------- | --------------------------------------------------------------- | +| 4 bytes | Value - unknown | +| 4 bytes | Always 0 | +| 2 bytes | Padding | +| 2 bytes | Always 1 | +| 4 bytes | Offset of this frame's 20-byte texture filename string | + +In practice this points at the very same filename strings that follow the frame table - the +two tables describe the same textures, reached two different ways. A material resolves its +texture name via the frame data table (`FrameNameOff`), not by reading the frame table's strings +directly. + +A material's `FrameOffset` of exactly 0 is a sentinel meaning "no texture", not a real offset - +real offsets always start at the frame table's own offset. + +#### Open questions + +- The 8 bytes of each frame table entry, and the 4-byte "Value" field of each frame data record. + +### Mesh table + +The mesh table (offset at 0x70) is an array of *mesh count* (0x44) fixed-size 160-byte records, +one per mesh: + +| Offset | Size | Description | +| ------ | -------- | ------------------------------------------------------------------------- | +| 0x00 | 16 bytes | Unknown | +| 0x10 | 64 bytes | 4x4 transform matrix (16 floats, row-major) - this mesh's placement | +| 0x50 | 4 bytes | Unknown | +| 0x54 | 4 bytes | Offset of this mesh's null-terminated ASCII name | +| 0x58 | 2 bytes | Vertex count | +| 0x5A | 2 bytes | Material count | +| 0x5C | 2 bytes | Face count | +| 0x5E | 2 bytes | Vertex order length (see **Vertex order**, below) | +| 0x60 | 4 bytes | Vertex data offset | +| 0x64 | 4 bytes | Unknown | +| 0x68 | 4 bytes | UV data offset | +| 0x6C | 4 bytes | Material table offset | +| 0x70 | 4 bytes | Face data offset | +| 0x74 | 4 bytes | Unknown | +| 0x78 | 12 bytes | Bounding box minimum (3 floats) - see **Vertex data** | +| 0x84 | 12 bytes | Bounding box maximum (3 floats) - see **Vertex data** | +| 0x90 | 4 bytes | Unknown | +| 0x94 | 4 bytes | Vertex order table offset (see **Vertex order**, below) | +| 0x98 | 8 bytes | Unknown | + +This mesh's bounding box (0x78/0x84) matters beyond just culling: it's also the box that +animation files quantise vertex positions into - see **Animation** below. + +#### Open questions + +- The four unknown fields (0x00, 0x50, 0x64, 0x74, 0x90, 0x98) - none have been narrowed down + beyond "not used by anything this project's renderer needs". + +### Vertex data + +Vertex positions are **not** stored as one `(x, y, z)` triple per vertex. They're grouped into +batches of up to 4 vertices, and within a batch, stored axis-major: four X floats, then four Y +floats, then four Z floats. The vertex count is padded up to the next multiple of 4 for this +purpose (trailing padding vertices are read but discarded). + +So for vertex count 5, the layout at the vertex data offset is: + +``` +X0 X1 X2 X3 X4 X? X? X? (two batches of 4 X floats each - second batch mostly padding) +Y0 Y1 Y2 Y3 Y4 Y? Y? Y? +Z0 Z1 Z2 Z3 Z4 Z? Z? Z? +``` + +This is the *raw* vertex list, indexed by source vertex index. It is not the order the mesh is +actually drawn in - see **Vertex order**, next. + +UV coordinates (at the UV data offset) use the same batches-of-4, axis-major layout, but the +count that matters here is the **vertex order length** (0x5E in the mesh record), not the raw +vertex count - UVs are stored per final, reordered vertex slot, not per source vertex. + +### Vertex order + +The vertex order table (offset at 0x94, length 0x5E) is an array of *vertex order length* +`ushort`s. Reading it produces the mesh's real, final vertex list: entry `i` of this table is +the source vertex index (into the raw vertex data above) that should become vertex `i` of the +drawn mesh. + +This reordering exists to group vertices contiguously by material: each material record (see +below) gives a `StartIndex`/`EndIndex` range, and a final vertex `i` uses whichever material's +range contains `i`. So the vertex order table isn't just a remap - it's what makes each +material's vertices sit in one unbroken run, which is what lets a material be described by a +single index range instead of a vertex list of its own. + +Face indices (see **Faces**, below) refer to this final, reordered vertex list - not the raw +vertex data. + +#### Open questions + +- Whether the vertex order table is ever used for anything beyond material grouping (e.g. + triangle strips or fans) - not observed in the game's data so far. + +### Faces + +The face table (offset given in the mesh record) is *face count* records of 8 bytes each: + +| Size | Description | +| ------- | ----------------------------------------------------- | +| 2 bytes | Unknown | +| 2 bytes | Vertex index A (into the reordered vertex list) | +| 2 bytes | Vertex index B | +| 2 bytes | Vertex index C | + +Each record is one triangle. Winding order needs to be reversed from how it's stored to render +correctly with a standard right-handed culling convention. + +#### Open questions + +- The leading unknown `ushort` of each face record. + +### Materials + +The material table (offset given in the mesh record, *material count* entries) uses 16-byte +records: + +| Size | Description | +| ------- | ---------------------------------------------------------------------- | +| 4 bytes | Frame offset - see **Textures**, above (0 = no texture) | +| 2 bytes | Unknown | +| 2 bytes | Unknown | +| 2 bytes | Start index - first reordered vertex index using this material | +| 2 bytes | End index - last reordered vertex index using this material (inclusive)| +| 4 bytes | Unknown | + +A material with a non-zero frame offset also has a 4-byte flags word, read directly from that +frame offset in the file (i.e. the frame offset does double duty: it locates both the frame +table entry for the texture name, and a flags value sitting at that same byte offset). + +#### Open questions + +- The two unidentified 2-byte fields, and the meaning of the flags word. + +### Normals + +Vertex normals aren't stored in the file at all - they're computed by accumulating each +triangle's face normal onto its three vertices and re-normalizing, the usual smooth-shading +approach. There's no format detail here; it's purely a rendering choice. + +## Animation + +An animation file has a mesh table offset of **exactly 0** at 0x70 - the same field that's a +real pointer for static meshes. Animation files carry no vertex positions, texture names, or +mesh geometry of their own; instead they carry one or more **tracks**, each of which poses part +of the base model (identified by the shared filename prefix) over a range of authoring frames. + +Across all 1279 animation files in the game, two distinct track kinds have been decoded, reached +via two different, independently-located tables within the same file: + +| Track kind | Files | Poses | +| ------------------ | -------------- | --------------------------------------------------- | +| Vertex (morph) | 282 (22%) | Reshapes a single mesh, vertex by vertex | +| Rotation | 686 (54%) | Turns one or more whole meshes about their own origin | +| *(undecoded)* | 311 (24%) | Loads with no tracks - see Open questions | + +No file in the game's data has been found to use both kinds together, though nothing in the +format rules it out - each kind's table is located independently of the other. + +### Vertex (morph) animation + +This kind reshapes exactly one mesh: a "channel" exists for every vertex of the mesh it +targets, plus two trailing channels whose purpose isn't identified, so the channel count is +always (target mesh's vertex count) + 2. That's how a consumer identifies which mesh an +animation drives - by matching this count against each of the base model's meshes. + +The track table is located via two header fields: + +| Offset | Size | Description | +| ------ | ------- | ------------------------------------ | +| 0xBA | 2 bytes | Track record count | +| 0xC4 | 4 bytes | Track record table offset | + +Some animation files put unrelated data in these two slots - notably the ones with an +undecoded track kind. A reader must validate the records below rather than trust this table +unconditionally, and must fail soft (skip the file) rather than throw when validation fails. + +Each record is 20 bytes: + +| Size | Description | +| ------- | ---------------------------------------------------------------------- | +| 2 bytes | Keyframe count (`a`) | +| 2 bytes | Channel count (`b`) | +| 4 bytes | Offset of a `b`-entry array of channel IDs (`ushort` each) | +| 4 bytes | Offset of an `a`-entry array of ascending frame indices (`ushort` each) | +| 4 bytes | Offset of an `a * b`-entry array of packed values (see below) | +| 4 bytes | Unknown | + +The channel ID and frame index arrays are each padded up to a 4-byte boundary, so the gap +between the channel ID array and the frame index array is `2*b` or `2*b + 2` bytes, and the gap +between the frame index array and the value array is `2*a` or `2*a + 2` bytes - a reader has to +accept either, not just the unpadded size. + +The value array is **entry-major**: the value for keyframe `e` of the record's channel slot `k` +sits at `valueOffset + (e * b + k) * 4` - i.e. all channels for keyframe 0 first, then all +channels for keyframe 1, and so on, not grouped by channel. + +Channels the animation doesn't actually move are still present, in their own record holding a +single keyframe of that channel's rest value - so a full mesh pose can always be reconstructed +by sampling every channel, moving or not. + +**Value decoding.** Each 4-byte value is a vertex position, quantised into three signed 10-bit +fields packed into the 32 bits - X in bits 0-9, Y in bits 10-19, Z in bits 20-29 (bits 30-31 +unused). Each field is a signed value from -512 to 511, mapped linearly onto the *owning mesh's* +bounding box (from its mesh table record - see **Mesh table**, above): -512 maps to that axis's +box minimum, +511 to its maximum. + +``` +component(raw, shift, min, max): + field = signed_10_bit((raw >> shift) & 0x3FF) + centre = (min + max) / 2 + return centre + field * (max - min) / 1023 +``` + +Verified against real game data to R² ≥ 0.999997 per axis (max error ~0.028 units, consistent +with exactly the quantisation step), by decoding every channel's rest keyframe and comparing +against a known mesh's actual vertex positions. + +#### Open questions + +- What the two trailing non-vertex channels represent. +- The meaning of the record's final 4-byte "unknown" field. +- How multiple `M`-suffixed animations of the same mesh are meant to be sequenced or blended - + nothing in the file format addresses this (see the note on `TRIGANIM` at the top of this + page). + +### Rotation animation + +This kind turns one or more whole meshes about their own origin - a gate's two doors, say, +independently swinging open. Unlike vertex animation, one file can drive several meshes at +once, each with its own track. + +The rotation track table sits inside a 72-byte **animation data block**, whose offset is given +by the header field at 0x98 (valid - i.e. actually pointing at a well-formed block - in 1278 of +the game's 1279 animation files): + +| Offset (from block start) | Size | Description | +| -------------------------- | ------- | ------------------------------------------------------------------ | +| 0x08 | 4 bytes | Last frame of the animation | +| 0x10 | 2 bytes | Sum of every track's keyframe count (a cross-check, not required for parsing) | +| 0x12 | 2 bytes | Track count | +| 0x2C | 4 bytes | Track table offset | + +The track table offset is trustworthy on its own terms: the table is exactly +`trackCount * 64` bytes long, and **ends exactly where the animation block begins** - i.e. +`tableOffset + trackCount * 64 == blockOffset`. That identity holds for 1189 of the 1279 +animation files and is the check a reader should apply; files that fail it should be treated +as not carrying a (decodable) rotation track table, not as an error. + +Each 64-byte **track descriptor** can carry more than one kind of channel, distinguished by bits +of a flags word - only the rotation channel (bit `0x8`) is decoded so far: + +| Offset (from descriptor start) | Size | Description | +| -------------------------------- | ------- | ------------------------------------------------------------ | +| 0x00 | 4 bytes | This track's own index (0-based, sequential - a validity check) | +| 0x04 | 4 bytes | Flags - which channel kinds this track carries (see below) | +| 0x10 | 2 bytes | Rotation keyframe count (meaningful only when flag `0x8` is set) | +| 0x14 | 4 bytes | Target index - see **Target resolution**, below | +| 0x1C | 4 bytes | Rotation keyframe data offset (meaningful only when flag `0x8` is set) | + +Counted across every track in every animation file in the game, the flags word's bits and the +data-pointer slot each one owns: + +| Flag bit | Data pointer (from descriptor start) | Tracks in the game | Decoded? | +| -------- | --------------------------------------- | ------------------- | -------- | +| 0x00008 | Count at +0x10, data at +0x1C | 3,039 | Yes - rotation, documented above | +| 0x00001 | Data at +0x18 | 3,039 | No | +| 0x01000 | Data at +0x28 | 1,058 | No | +| 0x10000 | Data at +0x2C | 704 | No | +| 0x20000 | Data at +0x30 | 2,178 | No | + +A track's flags aren't exclusive - one track can carry several channel kinds at once, each +sitting in its own slot of the same 64-byte descriptor. A reader only needs to look at the bit +for the channel kind it understands and can leave the others alone. + +**Rotation keyframes** are 20 bytes each, an array of *rotation keyframe count* entries at the +descriptor's data offset: + +| Size | Description | +| ------- | ----------------------------------------------------------| +| 2 bytes | Frame index | +| 2 bytes | Flags - observed values are only `0x0000` or `0xFFFF`; meaning unconfirmed | +| 4 bytes | Quaternion X | +| 4 bytes | Quaternion Y | +| 4 bytes | Quaternion Z | +| 4 bytes | Quaternion W | + +Frame indices strictly ascend within a track. Every one of the 3,039 rotation tracks in the +game's data decodes to a unit quaternion (length within 0.01 of 1) at every keyframe - that, +plus strictly-ascending frames, is what a reader should validate before trusting a track. + +**Target resolution.** The 4-byte target index at descriptor +0x14 addresses a node in the base +model, and for models whose only nodes are their meshes, that's simply the mesh index - two +rotation tracks with target indices 0 and 1 in `Jun_gateM1.MD2`/`Jun_gateM2.MD2` turn +`Jun_gate.MD2`'s two door meshes from identity to a quarter turn about the model's up axis and +back, i.e. the gate swinging open and shut. + +Models with additional hierarchy beyond their meshes index past the mesh list - `Advisor.MD2` +has 25 meshes but its animations reach target index 28. What those extra three nodes are (and +therefore what a target index in that range should actually move) is unresolved; a reader +should treat an out-of-range target index as "skip this track" rather than guess. + +#### Open questions + +- Data pointer meaning for flag bits `0x1`, `0x1000`, `0x10000` and `0x20000` - locations are + known (see the flags table above), record layout is not. +- The three extra hierarchy node(s) referenced by target indices beyond a model's mesh count. +- The meaning of the 20-byte keyframe's flags field. +- The remaining ~24% of animation files that validate against neither this table nor the vertex + animation table above - what track kind(s) they actually contain hasn't been identified. From 0466b3fdc6908c11d4d0e3b14f51d5175c5d9c53 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Tue, 8 Sep 2026 18:50:34 -0400 Subject: [PATCH 02/14] Rewrite the .md2 animation section around the track table Animation channels all hang off one 64-byte track descriptor rather than being separate formats, and documenting them that way makes the whole section simpler: each channel bit owns exactly one slot, so a reader can decode the kinds it understands and ignore the rest by construction. Adds the vertex morph and UV animation channels, which weren't documented at all, and corrects two things the previous version got wrong by omission: the morph table is reached through the track descriptor's pointer at +0x28 rather than living at a fixed header offset (which is why one animation can morph several meshes), and the target node at +0x14 is a ushort - +0x16 holds something unrelated and is nonzero on 595 rotation tracks. Every figure recomputed over the same population of all 1279 animation files so the numbers are consistent with each other. --- src/content/docs/formats/models.md | 308 +++++++++++++++++------------ 1 file changed, 177 insertions(+), 131 deletions(-) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index dc8f10a..7b9ac9b 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -13,13 +13,6 @@ holds for 1274 of the 1279 animation files in the game; the five exceptions are `ROTATE.MD2`, `FLY.MD2`, `anim.MD2` and `scatM1.md2`, which don't sit next to an identifiable base model. -Nothing in the format says how a model's animations are sequenced or when they should play - in -particular, a gate's "doors open" and "doors close" animations are just two more `M`-suffixed -files, indistinguishable from each other by anything in the `.md2` data. In the original game -this is almost certainly driven externally, by a `TRIGANIM` instruction in that ride's -[compiled script](/formats/rsse) - see the `.closed`/gate example on the -[RSS](/formats/rss) page, which triggers an animation by index in response to game state. - > This page reflects an ongoing reverse-engineering effort - see **Open questions** at the end > of each section for what isn't nailed down yet. Every offset and rule stated as fact here has > been checked against the game's full model data (over 2,300 files), not inferred from one or @@ -221,40 +214,126 @@ approach. There's no format detail here; it's purely a rendering choice. An animation file has a mesh table offset of **exactly 0** at 0x70 - the same field that's a real pointer for static meshes. Animation files carry no vertex positions, texture names, or -mesh geometry of their own; instead they carry one or more **tracks**, each of which poses part -of the base model (identified by the shared filename prefix) over a range of authoring frames. +mesh geometry of their own. Instead they carry a list of **tracks**, each posing one node of the +base model (identified by the shared filename prefix) over a range of authoring frames. + +A track is not one kind of animation. It is a bundle of independent **channels** - a fountain's +water mesh morphs its vertices, scrolls its texture and carries a timing scalar all on the same +track - and each channel kind owns its own slot in the track descriptor. That is what makes the +format safe to read incrementally: a channel you don't understand costs nothing, because its +data lives in a slot you simply don't read. -Across all 1279 animation files in the game, two distinct track kinds have been decoded, reached -via two different, independently-located tables within the same file: +Of the game's 1279 animation files, 1164 (91%) carry at least one of the three channel kinds +documented below. -| Track kind | Files | Poses | -| ------------------ | -------------- | --------------------------------------------------- | -| Vertex (morph) | 282 (22%) | Reshapes a single mesh, vertex by vertex | -| Rotation | 686 (54%) | Turns one or more whole meshes about their own origin | -| *(undecoded)* | 311 (24%) | Loads with no tracks - see Open questions | +### Locating the tracks -No file in the game's data has been found to use both kinds together, though nothing in the -format rules it out - each kind's table is located independently of the other. +The uint at 0x98 points at a 72-byte **animation block**. That pointer is valid in 1278 of the +1279 animation files. -### Vertex (morph) animation +| Offset (from block start) | Size | Description | +| -------------------------- | ------- | --------------------------------------------------- | +| 0x08 | 4 bytes | Last frame of the animation | +| 0x10 | 2 bytes | Sum of every track's rotation keyframe count (a cross-check, not needed to parse) | +| 0x12 | 2 bytes | Track count | +| 0x2C | 4 bytes | Track table offset | -This kind reshapes exactly one mesh: a "channel" exists for every vertex of the mesh it -targets, plus two trailing channels whose purpose isn't identified, so the channel count is -always (target mesh's vertex count) + 2. That's how a consumer identifies which mesh an -animation drives - by matching this count against each of the base model's meshes. +The track table offset is trustworthy on its own terms, which matters because there's nothing +else to check it against: the table is exactly `trackCount * 64` bytes and **ends exactly where +the animation block begins**, i.e. `tableOffset + trackCount * 64 == blockOffset`. That identity +holds for 1189 of the 1279 files. A reader should apply it and treat a file that fails as +carrying no readable animation, rather than reading a table that isn't one. -The track table is located via two header fields: +### Track descriptors -| Offset | Size | Description | -| ------ | ------- | ------------------------------------ | -| 0xBA | 2 bytes | Track record count | -| 0xC4 | 4 bytes | Track record table offset | +Each track is a 64-byte descriptor: -Some animation files put unrelated data in these two slots - notably the ones with an -undecoded track kind. A reader must validate the records below rather than trust this table -unconditionally, and must fail soft (skip the file) rather than throw when validation fails. +| Offset (from descriptor start) | Size | Description | +| -------------------------------- | ------- | ------------------------------------------------------ | +| 0x00 | 4 bytes | This track's own index (0-based, sequential - a validity check) | +| 0x04 | 4 bytes | Channel flags - see below | +| 0x0C | 4 bytes | A frame value, close to but not always the track's last keyframe | +| 0x10 | 2 bytes | Rotation keyframe count (channel `0x8` only) | +| 0x14 | 2 bytes | **Target node** - see **Target resolution** below | +| 0x16 | 2 bytes | Unknown, but **not** part of the target | +| 0x18 | 4 bytes | Data pointer for channel `0x1` (undecoded) | +| 0x1C | 4 bytes | Rotation keyframes (channel `0x8`) | +| 0x28 | 4 bytes | Vertex morph descriptor (channel `0x1000`) | +| 0x2C | 4 bytes | UV animation descriptor (channel `0x10000`) | +| 0x30 | 4 bytes | Timing scalar, 16.16 fixed point (channel `0x20000`) | + +The flag word at 0x04 says which channels the track carries, and every bit owns exactly one +slot. Counted across every track in every animation file in the game: + +| Flag bit | Owns slot | Channel | Decoded? | +| -------- | ---------- | --------------------------- | -------- | +| 0x00008 | count at +0x10, data at +0x1C | Rotation | Yes | +| 0x01000 | +0x28 | Vertex morph | Yes | +| 0x10000 | +0x2C | UV animation | Yes | +| 0x20000 | +0x30 | A 16.16 fixed-point scalar | Partly - the encoding is clear, the meaning isn't | +| 0x00001 | +0x18 | Unidentified | No | + +The correspondence is exact: across all 1279 files, no track sets one of those bits without +filling its slot, or fills a slot without setting the bit. + +> **The target at 0x14 is a ushort, not a uint.** 0x16 holds an unrelated value and is nonzero +> on 595 of the game's rotation tracks, so reading 32 bits there produces a garbage node index - +> large enough that a range check discards a track that was perfectly good. + +### Target resolution + +The target indexes the base model's **node** list, which for models with no extra hierarchy is +simply its mesh list. `Jun_gateM1.MD2` has two rotation tracks targeting nodes 0 and 1, which +are `Jun_gate.MD2`'s two door meshes. + +Some models have more nodes than meshes and index past the mesh list - `Advisor.MD2` has 25 +meshes but its animations reach node 28 - and a handful land *within* the mesh list on a mesh +that clearly isn't the intended one (`droidm2.MD2` names a 16-vertex mesh while carrying 3561 +morph channels). A range check alone is therefore not enough. For morph tracks there is a +reliable second test, described below; without one, a reader should prefer skipping a track to +applying it to the wrong mesh. + +### Rotation (bit 0x8) + +Turns a whole node about its own origin. The keyframe count is the **ushort** at descriptor +0x10 and the data offset the uint at 0x1C. Each keyframe is 20 bytes: -Each record is 20 bytes: +| Size | Description | +| ------- | ----------------------------------------------------------| +| 2 bytes | Frame index | +| 2 bytes | Flags - only `0x0000` and `0xFFFF` are observed; meaning unconfirmed | +| 4 bytes | Quaternion X | +| 4 bytes | Quaternion Y | +| 4 bytes | Quaternion Z | +| 4 bytes | Quaternion W | + +Frame indices strictly ascend within a track, and every one of the 3039 rotation tracks in the +game decodes to a unit quaternion (within 0.01) at every keyframe. That pair of properties is +what a reader should validate before trusting a track. + +Model space is Y-up, so a quarter turn about Y is a door swinging. `Jun_gateM1` takes the gate's +two doors from identity to a quarter turn, and `Jun_gateM2` is exactly the inverse - open, then +shut. + +### Vertex morph (bit 0x1000) + +Reshapes a mesh vertex by vertex. The slot at descriptor 0x28 points at a 16-byte descriptor: + +| Offset | Size | Description | +| ------- | ------- | ---------------------- | +| 0x02 | 2 bytes | Record count | +| 0x0C | 4 bytes | Record table offset | + +Each track has its **own** descriptor and its own channel space, so one animation morphs as many +meshes as it has morph tracks. 768 animation files carry morph tracks, 368 of them more than +one, 1766 tracks in total - `ratraceM1.MD2` morphs four meshes at once. + +> Because each morph track is self-contained, a reader that only looks at a fixed header offset +> finds just the first one. Three of `ratrace`'s four morphing meshes have 64 vertices each, so +> matching a track to a mesh by vertex count cannot tell them apart either - the target index is +> the only thing that can. + +Each record in the table is 20 bytes: | Size | Description | | ------- | ---------------------------------------------------------------------- | @@ -262,27 +341,31 @@ Each record is 20 bytes: | 2 bytes | Channel count (`b`) | | 4 bytes | Offset of a `b`-entry array of channel IDs (`ushort` each) | | 4 bytes | Offset of an `a`-entry array of ascending frame indices (`ushort` each) | -| 4 bytes | Offset of an `a * b`-entry array of packed values (see below) | +| 4 bytes | Offset of an `a * b`-entry array of packed values | | 4 bytes | Unknown | The channel ID and frame index arrays are each padded up to a 4-byte boundary, so the gap -between the channel ID array and the frame index array is `2*b` or `2*b + 2` bytes, and the gap -between the frame index array and the value array is `2*a` or `2*a + 2` bytes - a reader has to -accept either, not just the unpadded size. +between the channel IDs and the frame indices is `2*b` or `2*b + 2` bytes, and between the frame +indices and the values `2*a` or `2*a + 2`. A reader has to accept either. + +The value array is **entry-major**: the value for keyframe `e` of channel slot `k` sits at +`valueOffset + (e * b + k) * 4` - all channels of keyframe 0 first, then all channels of +keyframe 1, not grouped by channel. -The value array is **entry-major**: the value for keyframe `e` of the record's channel slot `k` -sits at `valueOffset + (e * b + k) * 4` - i.e. all channels for keyframe 0 first, then all -channels for keyframe 1, and so on, not grouped by channel. +A morph track has exactly **one channel per vertex of its target mesh, plus two** trailing +channels that aren't vertices. 457 of the 465 tracks whose base model resolves satisfy that +exactly, and the 8 that don't are the mistargeted ones described under **Target resolution** - +which makes `channelCount == targetMesh.vertexCount + 2` a good validity test as well as a +description. -Channels the animation doesn't actually move are still present, in their own record holding a -single keyframe of that channel's rest value - so a full mesh pose can always be reconstructed -by sampling every channel, moving or not. +Channels the animation doesn't actually move are still present, in a record holding a single +keyframe of that channel's rest value, so a full mesh pose is always reconstructible by sampling +every channel. -**Value decoding.** Each 4-byte value is a vertex position, quantised into three signed 10-bit -fields packed into the 32 bits - X in bits 0-9, Y in bits 10-19, Z in bits 20-29 (bits 30-31 -unused). Each field is a signed value from -512 to 511, mapped linearly onto the *owning mesh's* -bounding box (from its mesh table record - see **Mesh table**, above): -512 maps to that axis's -box minimum, +511 to its maximum. +**Value decoding.** Each 4-byte value is a vertex position quantised into three signed 10-bit +fields - X in bits 0-9, Y in 10-19, Z in 20-29 (bits 30-31 unused). Each field is a signed value +from -512 to 511 mapped linearly onto the *target mesh's* bounding box (from its mesh table +record): -512 is that axis's box minimum, +511 its maximum. ``` component(raw, shift, min, max): @@ -291,99 +374,62 @@ component(raw, shift, min, max): return centre + field * (max - min) / 1023 ``` -Verified against real game data to R² ≥ 0.999997 per axis (max error ~0.028 units, consistent -with exactly the quantisation step), by decoding every channel's rest keyframe and comparing -against a known mesh's actual vertex positions. +Verified to R² >= 0.999997 per axis (max error ~0.028 units, exactly the quantisation step) by +decoding every channel's rest keyframe and comparing against a known mesh's actual vertices. -#### Open questions - -- What the two trailing non-vertex channels represent. -- The meaning of the record's final 4-byte "unknown" field. -- How multiple `M`-suffixed animations of the same mesh are meant to be sequenced or blended - - nothing in the file format addresses this (see the note on `TRIGANIM` at the top of this - page). - -### Rotation animation - -This kind turns one or more whole meshes about their own origin - a gate's two doors, say, -independently swinging open. Unlike vertex animation, one file can drive several meshes at -once, each with its own track. - -The rotation track table sits inside a 72-byte **animation data block**, whose offset is given -by the header field at 0x98 (valid - i.e. actually pointing at a well-formed block - in 1278 of -the game's 1279 animation files): +### UV animation (bit 0x10000) -| Offset (from block start) | Size | Description | -| -------------------------- | ------- | ------------------------------------------------------------------ | -| 0x08 | 4 bytes | Last frame of the animation | -| 0x10 | 2 bytes | Sum of every track's keyframe count (a cross-check, not required for parsing) | -| 0x12 | 2 bytes | Track count | -| 0x2C | 4 bytes | Track table offset | - -The track table offset is trustworthy on its own terms: the table is exactly -`trackCount * 64` bytes long, and **ends exactly where the animation block begins** - i.e. -`tableOffset + trackCount * 64 == blockOffset`. That identity holds for 1189 of the 1279 -animation files and is the check a reader should apply; files that fail it should be treated -as not carrying a (decodable) rotation track table, not as an error. - -Each 64-byte **track descriptor** can carry more than one kind of channel, distinguished by bits -of a flags word - only the rotation channel (bit `0x8`) is decoded so far: - -| Offset (from descriptor start) | Size | Description | -| -------------------------------- | ------- | ------------------------------------------------------------ | -| 0x00 | 4 bytes | This track's own index (0-based, sequential - a validity check) | -| 0x04 | 4 bytes | Flags - which channel kinds this track carries (see below) | -| 0x10 | 2 bytes | Rotation keyframe count (meaningful only when flag `0x8` is set) | -| 0x14 | 4 bytes | Target index - see **Target resolution**, below | -| 0x1C | 4 bytes | Rotation keyframe data offset (meaningful only when flag `0x8` is set) | +Slides texture coordinates - this is how the game animates water. The slot at descriptor 0x2C +points at a 20-byte descriptor: -Counted across every track in every animation file in the game, the flags word's bits and the -data-pointer slot each one owns: +| Offset | Size | Description | +| ------- | ------- | -------------------------------------------- | +| 0x00 | 4 bytes | Entry count `n` | +| 0x04 | 4 bytes | Offset of the index table | +| 0x08 | 4 bytes | Total component count `c` | +| 0x0C | 4 bytes | Offset of the duration table | +| 0x10 | 4 bytes | Offset of the value table | -| Flag bit | Data pointer (from descriptor start) | Tracks in the game | Decoded? | -| -------- | --------------------------------------- | ------------------- | -------- | -| 0x00008 | Count at +0x10, data at +0x1C | 3,039 | Yes - rotation, documented above | -| 0x00001 | Data at +0x18 | 3,039 | No | -| 0x01000 | Data at +0x28 | 1,058 | No | -| 0x10000 | Data at +0x2C | 704 | No | -| 0x20000 | Data at +0x30 | 2,178 | No | +The three tables are contiguous, in the order index, value, duration, and exactly sized by those +two counts: -A track's flags aren't exclusive - one track can carry several channel kinds at once, each -sitting in its own slot of the same 64-byte descriptor. A reader only needs to look at the bit -for the channel kind it understands and can leave the others alone. +- **Index table**, `n * 4` bytes: per entry, a ushort first component and a ushort component + count. UV components are two per coordinate, so an entry covering a whole UV is `(2i, 2)`. +- **Value table**, `c * 8` bytes: per entry, its component count of start floats followed by the + same number of end floats. So a 2-component entry is `(u_start, v_start, u_end, v_end)`. +- **Duration table**, `n * 4` bytes: per entry, a ushort pair whose **high** half is the end + frame. -**Rotation keyframes** are 20 bytes each, an array of *rotation keyframe count* entries at the -descriptor's data offset: +All 690 UV channels in the game satisfy every one of those invariants - the per-entry component +counts sum to the stated total, and both `indexTable + 4n == valueTable` and +`valueTable + 8c == durationTable` hold without exception. -| Size | Description | -| ------- | ----------------------------------------------------------| -| 2 bytes | Frame index | -| 2 bytes | Flags - observed values are only `0x0000` or `0xFFFF`; meaning unconfirmed | -| 4 bytes | Quaternion X | -| 4 bytes | Quaternion Y | -| 4 bytes | Quaternion Z | -| 4 bytes | Quaternion W | +A channel that animates every UV of its mesh has the identity index table `(2i, 2)` with `n` +equal to the mesh's **vertex order length** (the UV count, not the vertex count). One that +animates a subset names the components it wants - `arcadeM1`'s `screen` mesh animates 6 entries +out of 53 UVs. -Frame indices strictly ascend within a track. Every one of the 3,039 rotation tracks in the -game's data decodes to a unit quaternion (length within 0.01 of 1) at every keyframe - that, -plus strictly-ascending frames, is what a reader should validate before trusting a track. +`Jun_isleM1.MD2` is a clear worked example: it scrolls all 128 of `Post Ripples01`'s UVs by +`(-1, -1)` over 100 frames, and 104 of the `Island` mesh's 298 UVs by the same delta - the +shoreline foam lapping the beach, with the rest of the island held still. -**Target resolution.** The 4-byte target index at descriptor +0x14 addresses a node in the base -model, and for models whose only nodes are their meshes, that's simply the mesh index - two -rotation tracks with target indices 0 and 1 in `Jun_gateM1.MD2`/`Jun_gateM2.MD2` turn -`Jun_gate.MD2`'s two door meshes from identity to a quarter turn about the model's up axis and -back, i.e. the gate swinging open and shut. +### Sequencing -Models with additional hierarchy beyond their meshes index past the mesh list - `Advisor.MD2` -has 25 meshes but its animations reach target index 28. What those extra three nodes are (and -therefore what a target index in that range should actually move) is unresolved; a reader -should treat an out-of-range target index as "skip this track" rather than guess. +Nothing in the format says how a model's `M1`, `M2`, ... animations are ordered, when they +should play, or whether they loop. A gate's "doors open" and "doors close" are just two files, +indistinguishable by anything in the `.md2` data. In the original game this is driven externally, +by a `TRIGANIM` instruction in that ride's [compiled script](/formats/rsse) - see the gate +example on the [RSS](/formats/rss) page. -#### Open questions +### Open questions -- Data pointer meaning for flag bits `0x1`, `0x1000`, `0x10000` and `0x20000` - locations are - known (see the flags table above), record layout is not. -- The three extra hierarchy node(s) referenced by target indices beyond a model's mesh count. -- The meaning of the 20-byte keyframe's flags field. -- The remaining ~24% of animation files that validate against neither this table nor the vertex - animation table above - what track kind(s) they actually contain hasn't been identified. +- The channel at flag `0x1` (data pointer at descriptor +0x18) - location known, contents not. +- The channel at flag `0x20000` is a single 16.16 fixed-point scalar at +0x30 rather than a + pointer, so it's a per-track constant rather than a keyframed channel. Values are small + fractions (0.01 - 0.15); what they scale is unknown. +- The frame-ish value at descriptor +0x0C, and the unknown 4 bytes ending each morph record. +- What the two extra channels beyond a morph track's vertex count represent. +- Why a few models' target indices don't land on the mesh the data clearly belongs to - i.e. + what the node list actually contains for models that have more nodes than meshes. +- The 90 animation files that fail the track table identity, and the 25 that pass it but carry + only the undecoded channel kinds. From 0c882d9c5c7108b96811ef46184ed927c01a5460 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Tue, 8 Sep 2026 19:27:34 -0400 Subject: [PATCH 03/14] Fold the engine-confirmed .md2 layout into the models page Marks items verified against the original engine's pointer-relocation routine, which states directly which fields are pointers and what the record strides are, rather than inferring them from the data files. Corrects the flag 0x20000 entry: it is a pointer to undecoded data, not an inline 16.16 fixed-point scalar. The previously documented "small fractions" were file offsets divided by 65536. Adds: the versions at 0x04/0x08, the total node count at 0x42 that explains targets landing past the mesh list, the non-mesh node table at 0x74, the header's pointer block, the three further descriptor pointer slots, and the 0x4000 modifier bit that reinterprets the morph slot. Downgrades flag 0x1 from "unknown" to partially characterised, without claiming it is decoded - the record shape only holds for about 58% of them. --- src/content/docs/formats/models.md | 82 ++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index 7b9ac9b..2356b38 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -17,6 +17,12 @@ base model. > of each section for what isn't nailed down yet. Every offset and rule stated as fact here has > been checked against the game's full model data (over 2,300 files), not inferred from one or > two examples. +> +> Items marked **(engine-confirmed)** were additionally checked against the original game's own +> pointer-relocation routine, which walks a freshly loaded `.md2` converting every stored file +> offset into an absolute pointer. What it relocates is a pointer and what it skips is not, and +> the counts and strides it loops with are the record sizes - so those are direct statements of +> the format rather than inferences from the data. ## Shared header @@ -24,27 +30,40 @@ Every `.md2` file, mesh or animation, opens with the same header shape. Most of unidentified - the table below lists only the fields this project's parser actually depends on; everything else is a gap of unknown content, not a claim that nothing is there. +| Offset | Size | Description | +| ------ | -------- | ------------------------------------------------------------------------------------- | | Offset | Size | Description | | ------ | -------- | ------------------------------------------------------------------------------------- | | 0x00 | 4 bytes | Magic number - `46 5D D1 1C` (little-endian `0x1CD15D46`) | -| 0x04 | 4 bytes | Constant - always `0xDD` | -| 0x08 | 4 bytes | Constant - always `0xCB` | -| 0x0C | 4 bytes | Unknown - varies per file | +| 0x04 | 4 bytes | **Format version** - the engine requires `<= 0xDD` (engine-confirmed) | +| 0x08 | 4 bytes | **Animation format version** - must be exactly `0xCB` or the animation block is discarded (engine-confirmed) | +| 0x30 | 1 byte | Flags; bit 0 gates the whole mesh/material fixup pass (engine-confirmed) | | 0x36 | 2 bytes | Frame/texture count | -| 0x44 | 2 bytes | Mesh count | +| 0x40 | 2 bytes | Count for the table at 0xAC (engine-confirmed) | +| 0x42 | 2 bytes | **Total node count** - see **Target resolution** under Animation (engine-confirmed) | +| 0x44 | 2 bytes | Mesh count - the meshes are the *first* 0x44 of the 0x42 nodes | +| 0x48 | 2 bytes | Count for the table at 0x7C (engine-confirmed) | +| 0x4C | 4 bytes | Pointer, purpose unknown (engine-confirmed pointer) | | 0x50 | 4 bytes | Frame table offset (see **Textures**, below) | | 0x54 | 4 bytes | Frame data table offset (see **Textures**, below) | +| 0x58 - 0x6C | 4 bytes each | Six pointers, purposes unknown (engine-confirmed pointers) | | 0x70 | 4 bytes | Mesh table offset - **0 marks this file as animation data**, see **Animation** below | +| 0x74 | 4 bytes | Non-mesh node table - `(0x42 - 0x44)` records of 88 bytes (engine-confirmed) | +| 0x78 | 4 bytes | Pointer, purpose unknown (engine-confirmed pointer) | +| 0x7C | 4 bytes | Table of 0x48 records, 20 bytes each (engine-confirmed) | | 0x80 | - | Start of the model's overall bounding box (not yet parsed - see Open questions) | | 0x98 | 4 bytes | Animation data block offset - animation files only, see **Animation** below | +| 0xAC | 4 bytes | Table of 0x40 records, 16 bytes each (engine-confirmed) | -Everything from 0x10 to 0x36, 0x38 to 0x44, and 0x46 to 0x50 is an unidentified gap. There is -almost certainly more structure in 0x80 onward that this project doesn't yet read for static -meshes either. +The engine relocates pointers at 0x4C, 0x50, 0x54, 0x58, 0x5C, 0x60, 0x64, 0x68, 0x6C, 0x70, +0x74, 0x78, 0x7C, 0x98 and 0xAC, so that whole run is a pointer block even where the purpose of +an individual entry is still unknown. It also writes its own base pointer to 0x9C and the raw +allocation to 0xB0 at load time, so those two are runtime scratch rather than file content. ### Open questions -- The meaning of the 0x0C field, and everything in the unidentified gaps above. +- The meaning of the 0x0C field, and of the six pointers between 0x58 and 0x6C. +- What the non-mesh nodes at 0x74 actually are, and what the 88-byte record contains. - The exact shape of the bounding box at 0x80 (min/max as two vectors, one vector plus extents, etc.) - its presence is inferred only from animation files never containing a float triple that reproduces it, not from having parsed it directly. @@ -258,9 +277,12 @@ Each track is a 64-byte descriptor: | 0x16 | 2 bytes | Unknown, but **not** part of the target | | 0x18 | 4 bytes | Data pointer for channel `0x1` (undecoded) | | 0x1C | 4 bytes | Rotation keyframes (channel `0x8`) | +| 0x20 | 4 bytes | Pointer, channel unidentified (engine-confirmed) | +| 0x24 | 4 bytes | Pointer, channel unidentified (engine-confirmed) | | 0x28 | 4 bytes | Vertex morph descriptor (channel `0x1000`) | | 0x2C | 4 bytes | UV animation descriptor (channel `0x10000`) | -| 0x30 | 4 bytes | Timing scalar, 16.16 fixed point (channel `0x20000`) | +| 0x30 | 4 bytes | Data pointer for channel `0x20000` (undecoded) | +| 0x34 | 4 bytes | Pointer, channel unidentified (engine-confirmed) | The flag word at 0x04 says which channels the track carries, and every bit owns exactly one slot. Counted across every track in every animation file in the game: @@ -270,12 +292,21 @@ slot. Counted across every track in every animation file in the game: | 0x00008 | count at +0x10, data at +0x1C | Rotation | Yes | | 0x01000 | +0x28 | Vertex morph | Yes | | 0x10000 | +0x2C | UV animation | Yes | -| 0x20000 | +0x30 | A 16.16 fixed-point scalar | Partly - the encoding is clear, the meaning isn't | +| 0x20000 | +0x30 | Unidentified | No | | 0x00001 | +0x18 | Unidentified | No | The correspondence is exact: across all 1279 files, no track sets one of those bits without filling its slot, or fills a slot without setting the bit. +The descriptor holds **eight** pointer slots in all - 0x18, 0x1C, 0x20, 0x24, 0x28, 0x2C, 0x30 +and 0x34 - every one of which the engine relocates (engine-confirmed). So the three with no +flag bit identified yet (0x20, 0x24, 0x34) are further channels, not padding. + +> **Bit `0x4000` is a modifier, not a channel.** It makes the `+0x28` slot point at a different +> structure, and the engine branches on it *before* reading any morph table. Twelve tracks in +> the game set it, always alongside `0x1000`. Reading those as vertex morph follows offsets into +> the wrong structure, so a reader must exclude them. + > **The target at 0x14 is a ushort, not a uint.** 0x16 holds an unrelated value and is nonzero > on 595 of the game's rotation tracks, so reading 32 bits there produces a garbage node index - > large enough that a range check discards a track that was perfectly good. @@ -286,12 +317,20 @@ The target indexes the base model's **node** list, which for models with no extr simply its mesh list. `Jun_gateM1.MD2` has two rotation tracks targeting nodes 0 and 1, which are `Jun_gate.MD2`'s two door meshes. -Some models have more nodes than meshes and index past the mesh list - `Advisor.MD2` has 25 -meshes but its animations reach node 28 - and a handful land *within* the mesh list on a mesh -that clearly isn't the intended one (`droidm2.MD2` names a 16-vertex mesh while carrying 3561 -morph channels). A range check alone is therefore not enough. For morph tracks there is a -reliable second test, described below; without one, a reader should prefer skipping a track to -applying it to the wrong mesh. +The **total node count is the ushort at the model's 0x42**, and the meshes are only the first +`0x44` of those nodes (engine-confirmed - the loader walks the remainder as a separate table of +88-byte records at header 0x74). `Advisor.MD2` has 29 nodes and 25 meshes, which is why its +animations reach node 28. + +Across the game, **5934 of 5940 animation targets fall inside the node count** against only 4544 +inside the mesh count - so a target above the mesh count is a real node the model simply has no +geometry for, not a misread. A reader with no representation for non-mesh nodes should skip +those tracks. + +A range check alone is still not quite enough: a handful of targets land *within* the mesh list +on a mesh that clearly isn't the intended one (`droidm2.MD2` names a 16-vertex mesh while +carrying 3561 morph channels). For morph tracks there is a reliable second test, described +below. ### Rotation (bit 0x8) @@ -423,10 +462,13 @@ example on the [RSS](/formats/rss) page. ### Open questions -- The channel at flag `0x1` (data pointer at descriptor +0x18) - location known, contents not. -- The channel at flag `0x20000` is a single 16.16 fixed-point scalar at +0x30 rather than a - pointer, so it's a per-track constant rather than a keyframed channel. Values are small - fractions (0.01 - 0.15); what they scale is unknown. +- The channel at flag `0x1` (pointer at descriptor +0x18). Partially characterised: it leads to + a repeating record of `u32 tag, u16 a, u16 b, ptr, ptr`, where the second pointer is clearly a + frame index array (`0, 10, 20, 30` in `Advisorm14`) and the first is an `a * 12`-byte payload. + The record shape only holds for about 58% of instances, so there are variants and this is not + decoded. +- The channel at flag `0x20000` (pointer at descriptor +0x30) - location known, contents not. +- The three further pointer slots at +0x20, +0x24 and +0x34, and which flag bits own them. - The frame-ish value at descriptor +0x0C, and the unknown 4 bytes ending each morph record. - What the two extra channels beyond a morph track's vertex count represent. - Why a few models' target indices don't land on the mesh the data clearly belongs to - i.e. From 7edf91cbd5bf93f569930f7bd25e49d2a9655765 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Tue, 8 Sep 2026 19:43:08 -0400 Subject: [PATCH 04/14] Correct the animation statistics and complete the descriptor slot map Four figures on this page were measured across a game folder that holds the data tree twice, so they were exactly doubled. Restated against one tree: 2970 animation targets, 2967 inside the node count, 2272 inside the mesh count, 2536 tracks carrying a +0x30 slot. The conclusions are unchanged. Also corrects the count of tracks setting the 0x4000 modifier (30, not 12), and the characterisation of the 90 unreadable files: they do not fail the track table identity, they contain no animation - 89 declare zero tracks and one has no animation block. The identity holds on every file in the game. Completes the descriptor slot map with two further exact bit/slot pairs (0x80+0x100 -> +0x20, 0x200 -> +0x24), leaving +0x34 documented as an optional extra for rotation rather than a channel of its own. Adds the engine-confirmed static mesh fields: the flags word at mesh record 0x00, the pointer at 0x64, the frame record layout, and the flags byte that begins each 8-byte frame table entry. --- src/content/docs/formats/models.md | 103 ++++++++++++++++++----------- 1 file changed, 64 insertions(+), 39 deletions(-) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index 2356b38..dab2b37 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -30,8 +30,6 @@ Every `.md2` file, mesh or animation, opens with the same header shape. Most of unidentified - the table below lists only the fields this project's parser actually depends on; everything else is a gap of unknown content, not a claim that nothing is there. -| Offset | Size | Description | -| ------ | -------- | ------------------------------------------------------------------------------------- | | Offset | Size | Description | | ------ | -------- | ------------------------------------------------------------------------------------- | | 0x00 | 4 bytes | Magic number - `46 5D D1 1C` (little-endian `0x1CD15D46`) | @@ -85,13 +83,13 @@ material's index into it. The frame data table (offset at 0x54) is a second, parallel array of *frame count* 16-byte records, indexed the same way: -| Size | Description | -| ------- | --------------------------------------------------------------- | -| 4 bytes | Value - unknown | -| 4 bytes | Always 0 | -| 2 bytes | Padding | -| 2 bytes | Always 1 | -| 4 bytes | Offset of this frame's 20-byte texture filename string | +| Offset | Size | Description | +| ------- | ------- | ------------------------------------------------------------------------ | +| 0x00 | 4 bytes | Flags - bit 0 is tested by the engine (engine-confirmed) | +| 0x04 | 4 bytes | Zeroed at load time, so runtime scratch rather than file data (engine-confirmed) | +| 0x08 | 2 bytes | Padding | +| 0x0A | 2 bytes | Set to 1 by the engine under one load option (engine-confirmed) | +| 0x0C | 4 bytes | Offset of this frame's 20-byte texture filename string (engine-confirmed pointer) | In practice this points at the very same filename strings that follow the frame table - the two tables describe the same textures, reached two different ways. A material resolves its @@ -101,9 +99,14 @@ directly. A material's `FrameOffset` of exactly 0 is a sentinel meaning "no texture", not a real offset - real offsets always start at the frame table's own offset. +The 8-byte frame table entries also begin with a **flags byte**: the engine tests bits `0x40` +and `0x80` of byte 0 and propagates them into the model-wide flags at header 0x30 +(engine-confirmed). + #### Open questions -- The 8 bytes of each frame table entry, and the 4-byte "Value" field of each frame data record. +- The remaining 7 bytes of each frame table entry, and which bits of the two flags fields mean + what. ### Mesh table @@ -112,7 +115,8 @@ one per mesh: | Offset | Size | Description | | ------ | -------- | ------------------------------------------------------------------------- | -| 0x00 | 16 bytes | Unknown | +| 0x00 | 4 bytes | **Flags** - the engine tests `0x200` and sets `0x20`/`0x80000010` (engine-confirmed) | +| 0x04 | 12 bytes | Three pointers, purposes unknown (engine-confirmed pointers) | | 0x10 | 64 bytes | 4x4 transform matrix (16 floats, row-major) - this mesh's placement | | 0x50 | 4 bytes | Unknown | | 0x54 | 4 bytes | Offset of this mesh's null-terminated ASCII name | @@ -121,7 +125,7 @@ one per mesh: | 0x5C | 2 bytes | Face count | | 0x5E | 2 bytes | Vertex order length (see **Vertex order**, below) | | 0x60 | 4 bytes | Vertex data offset | -| 0x64 | 4 bytes | Unknown | +| 0x64 | 4 bytes | Pointer, purpose unknown (engine-confirmed pointer) | | 0x68 | 4 bytes | UV data offset | | 0x6C | 4 bytes | Material table offset | | 0x70 | 4 bytes | Face data offset | @@ -135,10 +139,16 @@ one per mesh: This mesh's bounding box (0x78/0x84) matters beyond just culling: it's also the box that animation files quantise vertex positions into - see **Animation** below. +A node index below the model's mesh count selects one of these 160-byte records; a higher index +selects an 88-byte record from the table at header 0x74 instead (engine-confirmed) - see +**Target resolution** under Animation. + #### Open questions -- The four unknown fields (0x00, 0x50, 0x64, 0x74, 0x90, 0x98) - none have been narrowed down - beyond "not used by anything this project's renderer needs". +- The remaining unknown fields (0x50, 0x74, 0x90, 0x98) and the three pointers at 0x04-0x0C - + none have been narrowed down beyond "not used by anything this project's renderer needs". +- Which bits of the flags word at 0x00 mean what; only `0x200`, `0x20` and `0x80000010` are + observed being tested or set. ### Vertex data @@ -242,8 +252,11 @@ track - and each channel kind owns its own slot in the track descriptor. That is format safe to read incrementally: a channel you don't understand costs nothing, because its data lives in a slot you simply don't read. -Of the game's 1279 animation files, 1164 (91%) carry at least one of the three channel kinds -documented below. +Of the game's 1279 animation files, **1151 (90%)** carry at least one of the three channel kinds +documented below. A further 38 have a readable track table but carry only undecoded channels. +The remaining 90 contain no animation at all - 89 declare a track count of zero and one has no +animation block. **The track table identity never fails on any file in the game**, so nothing is +rejected for being unreadable. ### Locating the tracks @@ -287,23 +300,29 @@ Each track is a 64-byte descriptor: The flag word at 0x04 says which channels the track carries, and every bit owns exactly one slot. Counted across every track in every animation file in the game: -| Flag bit | Owns slot | Channel | Decoded? | -| -------- | ---------- | --------------------------- | -------- | -| 0x00008 | count at +0x10, data at +0x1C | Rotation | Yes | -| 0x01000 | +0x28 | Vertex morph | Yes | -| 0x10000 | +0x2C | UV animation | Yes | -| 0x20000 | +0x30 | Unidentified | No | -| 0x00001 | +0x18 | Unidentified | No | - -The correspondence is exact: across all 1279 files, no track sets one of those bits without -filling its slot, or fills a slot without setting the bit. - -The descriptor holds **eight** pointer slots in all - 0x18, 0x1C, 0x20, 0x24, 0x28, 0x2C, 0x30 -and 0x34 - every one of which the engine relocates (engine-confirmed). So the three with no -flag bit identified yet (0x20, 0x24, 0x34) are further channels, not padding. +| Flag bit | Owns slot | Channel | Tracks | Decoded? | +| ----------- | ----------------------------- | ------------ | ------ | -------- | +| `0x00008` | count at +0x10, data at +0x1C | Rotation | 3039 | Yes | +| `0x01000` | +0x28 | Vertex morph | 1766 | Yes | +| `0x10000` | +0x2C | UV animation | 690 | Yes | +| `0x20000` | +0x30 | Unidentified | 2536 | No | +| `0x00001` | +0x18 | Unidentified | 1216 | No | +| `0x80`+`0x100` | +0x20 | Unidentified | 644 | No | +| `0x00200` | +0x24 | Unidentified | 71 | No | + +Every one of those correspondences is **exact** - across all 1279 files, not one track sets a +bit without filling its slot or fills a slot without setting the bit. `0x80` and `0x100` always +appear together and share the single slot at +0x20. + +There is an eighth pointer, at **+0x34, that no flag bit owns**. It is set on 1768 tracks and +every one of them is a rotation track (out of 3039), so it is an optional extra *for rotation* +rather than a channel in its own right. What it points at looks like a byte ramp - +`32, 66, 105, 141, 176, 208, 233, 249` in `Advisorm1` - which would be an easing curve, but the +records are not a fixed length and about a third are not monotonic, so that is an observation +rather than a decode. > **Bit `0x4000` is a modifier, not a channel.** It makes the `+0x28` slot point at a different -> structure, and the engine branches on it *before* reading any morph table. Twelve tracks in +> structure, and the engine branches on it *before* reading any morph table. Thirty tracks in > the game set it, always alongside `0x1000`. Reading those as vertex morph follows offsets into > the wrong structure, so a reader must exclude them. @@ -318,11 +337,16 @@ simply its mesh list. `Jun_gateM1.MD2` has two rotation tracks targeting nodes 0 are `Jun_gate.MD2`'s two door meshes. The **total node count is the ushort at the model's 0x42**, and the meshes are only the first -`0x44` of those nodes (engine-confirmed - the loader walks the remainder as a separate table of -88-byte records at header 0x74). `Advisor.MD2` has 29 nodes and 25 meshes, which is why its -animations reach node 28. +`0x44` of those nodes. The engine indexes a node directly (engine-confirmed): + +```c +if (node < meshCount) ptr = meshTable(0x70) + node * 0xA0; // 160-byte mesh record +else ptr = nodeTable(0x74) + (node - meshCount) * 0x58; // 88-byte node record +``` + +`Advisor.MD2` has 29 nodes and 25 meshes, which is why its animations reach node 28. -Across the game, **5934 of 5940 animation targets fall inside the node count** against only 4544 +Across the game, **2967 of 2970 animation targets fall inside the node count** against only 2272 inside the mesh count - so a target above the mesh count is a real node the model simply has no geometry for, not a misread. A reader with no representation for non-mesh nodes should skip those tracks. @@ -364,8 +388,9 @@ Reshapes a mesh vertex by vertex. The slot at descriptor 0x28 points at a 16-byt | 0x0C | 4 bytes | Record table offset | Each track has its **own** descriptor and its own channel space, so one animation morphs as many -meshes as it has morph tracks. 768 animation files carry morph tracks, 368 of them more than -one, 1766 tracks in total - `ratraceM1.MD2` morphs four meshes at once. +meshes as it has morph tracks. 752 animation files carry readable morph tracks, 360 of them +more than one - `ratraceM1.MD2` morphs four meshes at once. There are 1766 morph tracks in all, +30 of which set `0x4000` and are skipped. > Because each morph track is self-contained, a reader that only looks at a fixed header offset > finds just the first one. Three of `ratrace`'s four morphing meshes have 64 vertices each, so @@ -392,8 +417,8 @@ The value array is **entry-major**: the value for keyframe `e` of channel slot ` keyframe 1, not grouped by channel. A morph track has exactly **one channel per vertex of its target mesh, plus two** trailing -channels that aren't vertices. 457 of the 465 tracks whose base model resolves satisfy that -exactly, and the 8 that don't are the mistargeted ones described under **Target resolution** - +channels that aren't vertices. 457 of the 464 tracks whose base model resolves satisfy that +exactly, and the 7 that don't are the mistargeted ones described under **Target resolution** - which makes `channelCount == targetMesh.vertexCount + 2` a good validity test as well as a description. From 4f9f8366fd17ad52c59b0dae6f46ecfd6dc59288 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Tue, 8 Sep 2026 19:55:55 -0400 Subject: [PATCH 05/14] Document the .md2 node hierarchy A model is a tree of nodes with parent-relative transforms, not a flat mesh list. Adds the shared node header (flags, parent, sibling, child, transform, name), the engine's indexing rule across the two node tables, and the worked Jun_isle example whose trees store their parts at a local origin. This also answers the open question about the non-mesh nodes at 0x74: they are transform-only nodes, which is why an animation target can point past the mesh count. --- src/content/docs/formats/models.md | 54 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index dab2b37..900b50b 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -46,7 +46,7 @@ everything else is a gap of unknown content, not a claim that nothing is there. | 0x54 | 4 bytes | Frame data table offset (see **Textures**, below) | | 0x58 - 0x6C | 4 bytes each | Six pointers, purposes unknown (engine-confirmed pointers) | | 0x70 | 4 bytes | Mesh table offset - **0 marks this file as animation data**, see **Animation** below | -| 0x74 | 4 bytes | Non-mesh node table - `(0x42 - 0x44)` records of 88 bytes (engine-confirmed) | +| 0x74 | 4 bytes | Transform-only node table - `(0x42 - 0x44)` records of 88 bytes - see **Node hierarchy** | | 0x78 | 4 bytes | Pointer, purpose unknown (engine-confirmed pointer) | | 0x7C | 4 bytes | Table of 0x48 records, 20 bytes each (engine-confirmed) | | 0x80 | - | Start of the model's overall bounding box (not yet parsed - see Open questions) | @@ -61,7 +61,6 @@ allocation to 0xB0 at load time, so those two are runtime scratch rather than fi ### Open questions - The meaning of the 0x0C field, and of the six pointers between 0x58 and 0x6C. -- What the non-mesh nodes at 0x74 actually are, and what the 88-byte record contains. - The exact shape of the bounding box at 0x80 (min/max as two vectors, one vector plus extents, etc.) - its presence is inferred only from animation files never containing a float triple that reproduces it, not from having parsed it directly. @@ -72,6 +71,57 @@ A static mesh file has a non-zero mesh table offset at 0x70. Everything the mesh vertices, UVs, faces, materials, texture names - is reachable from the header fields above plus the per-mesh table this section describes. +### Node hierarchy + +**A model is a tree of nodes, not a flat list of meshes**, and a node's transform is relative to +its parent. Ignoring that leaves child meshes piled at the model origin. + +The ushort at 0x42 is the total node count and the ushort at 0x44 the mesh count. The meshes are +the first `0x44` nodes, in the 160-byte records at 0x70; the remainder are **transform-only +nodes** in 88-byte records at 0x74. The engine indexes them with one rule (engine-confirmed): + +```c +if (node < meshCount) ptr = meshTable(0x70) + node * 0xA0; +else ptr = nodeTable(0x74) + (node - meshCount) * 0x58; +``` + +Both record kinds begin with the same header, which is what makes that work: + +| Offset | Size | Description | +| ------ | -------- | ---------------------------------------------------------------------- | +| 0x00 | 4 bytes | Flags - bit `0x200` marks a transform-only node | +| 0x04 | 4 bytes | **Parent** node (a file offset; 0 for a root) | +| 0x08 | 4 bytes | **Next sibling** node | +| 0x0C | 4 bytes | **First child** node | +| 0x10 | 64 bytes | This node's transform, relative to its parent | +| 0x54 | 4 bytes | Offset of the node's null-terminated ASCII name | + +The three links are stored as file offsets into whichever of the two tables the target lives in, +so a reader converts an offset back to a node index by testing which table's range it falls in. +Bit `0x200` is how the engine tells the kinds apart - it skips material processing for any node +that has it set. + +`Jun_isle.MD2` shows why this matters. Its three palm trees hang off two dummy nodes plus the +island mesh: + +``` + 0 Island parent - firstChild node[25] +25 l_tree1 parent mesh[0] firstChild mesh[1] local (-11.92, 8.99, 8.40) + 1 Box77 parent node[25] sibling mesh[2] local ( 0.00, 0.00, 0.00) + 2 Box78 parent node[25] local ( 0.00, 0.00, 0.00) +26 l_tree2 parent mesh[0] firstChild mesh[7] local ( 2.28, 14.88, 8.81) + 7 Box69 parent node[26] sibling mesh[8] local ( -1.15, -1.83, 0.00) + 8 Box70 parent node[26] local ( 0.00, 3.66, 0.00) +``` + +Two of the trees store their trunk and leaves at a local `(0, 0, 0)` and `(0, 3.66, 0)` - the +leaves are 3.66 units above *their trunk*, and mean nothing until `l_tree1`/`l_tree2` supply the +position. Read flat, those four meshes land on top of each other at the model origin. + +Animation targets index this same node list, which is why a target can legitimately point past +the mesh count: it is naming a transform-only node. Rotating such a node should carry its whole +subtree. + ### Textures The frame table (offset at 0x50) is an array of *frame count* (0x36) 8-byte entries, immediately From 64387d17d665f6cee4bc95f0c498d029b2e53442 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Tue, 8 Sep 2026 20:11:33 -0400 Subject: [PATCH 06/14] Warn that .md2 node transforms can be sheared 130 of the game's 7533 nodes have non-perpendicular axes, which a translation/rotation/scale decomposition cannot represent. Jun_isle's tallest palm trunk is one, and decomposing moves it over 5 units out of place. --- src/content/docs/formats/models.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index 900b50b..8e38f4e 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -122,6 +122,12 @@ Animation targets index this same node list, which is why a target can legitimat the mesh count: it is naming a transform-only node. Rotating such a node should carry its whole subtree. +> **Don't decompose these transforms into translation/rotation/scale.** About 1.7% of nodes in +> the game (130 of 7533) are *sheared* - their axes are not perpendicular - and a TRS cannot +> represent that, so the shear is silently dropped. `Jun_isle`'s tallest palm trunk is one of +> them, and decomposing skewed it more than 5 units out of place, into the dinosaur that stands +> next to it. Keep the 4x4 and multiply it. + ### Textures The frame table (offset at 0x50) is an array of *frame count* (0x36) 8-byte entries, immediately From fe8da05a4d6daae49c40eacb2a58a076c6681b20 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 12:32:57 -0400 Subject: [PATCH 07/14] Document the park sign format (.sgn) Each park's sign board is a .sgn beside the model that carries its sign1 and sign2 materials - named after that model, so the isle for jungle and hallow but the gate for fantasy and space. Everything from 0x43C5 turns out to be a .wct v4 image body with its header fields rearranged, so an existing texture decoder handles it unchanged. The image is a blank 256x128 board; the park name is composited on top at runtime from the TrueType font the file names. The evidence is in the compression: all four parks inflate to the same 98304 bytes, but their compressed sizes track the length of the name (5607 bytes for "Space", 14606 for "Fantasy Island"). The name itself comes from the ISLAND() line in the park's script at the root of lobby.wad. Also records how the sheet maps onto the model - two 128x128 panels side by side, each drawn twice and mirrored so the sign reads from both sides - and marks what is still unknown: five of the eight floats per font record, and a 16KB block at 0x03C5 that decodes to a tiling pattern rather than to the sign. --- src/content/docs/formats/sgn.md | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/content/docs/formats/sgn.md diff --git a/src/content/docs/formats/sgn.md b/src/content/docs/formats/sgn.md new file mode 100644 index 0000000..8f78a3f --- /dev/null +++ b/src/content/docs/formats/sgn.md @@ -0,0 +1,104 @@ +--- +title: Park signs (*.sgn) +--- + +A `.sgn` holds everything a park's name board needs: the artwork painted on the board, and the +fonts its name is lettered in. The name itself is not in here — it comes from the park's script +(see [Where the name comes from](#where-the-name-comes-from)). + +Five ship with the game: + +| File | Park | +| --- | --- | +| `lobby.wad` → `terrain\Jun_isle.sgn` | Jungle ("Lost Kingdom") | +| `lobby.wad` → `terrain\Hal_isle.sgn` | Hallow ("Halloween") | +| `lobby.wad` → `terrain\Fan_gate.sgn` | Fantasy | +| `lobby.wad` → `terrain\Spa_gate.sgn` | Space | +| `levels\\features\sign1.wad` → `sign1.sgn` | the placeable in-park sign | + +Note the inconsistent naming. A `.sgn` is named after **the model that carries the `sign1` and +`sign2` materials**, which is the island model for jungle and hallow but the gate model for +fantasy and space — so a lookup has to try both rather than assume either. + +## File format + +The header is a fixed size: the image body starts at `0x43DD` in all five files. + +| Offset | Size | Description | +| --- | --- | --- | +| `0x0000` | 17 bytes | Unknown | +| `0x0011` | 436 bytes | Font record 0 | +| `0x01C5` | 436 bytes | Font record 1 | +| `0x0379` | 76 bytes | Unknown | +| `0x03C5` | 16384 bytes | 32-bit pixel data — see below | +| `0x43C5` | 4 x float | Y, Cb, Cr and A quantisation scales (`6, 10, 2, 6` in all five files) | +| `0x43D5` | 4 bytes | Colour chunk size | +| `0x43D9` | 4 bytes | Alpha chunk size | +| `0x43DD` | | Colour chunk, then alpha chunk | + +### Font record + +| Offset | Size | Description | +| --- | --- | --- | +| `+0x000` | 64 bytes | Display name, NUL-padded ASCII (e.g. `Young Itch AOE`) | +| `+0x040` | 64 bytes | TrueType file name (e.g. `YOUNIA__.TTF`) — the file itself is in `fonts.wad` | +| `+0x168` | 64 bytes | Display name again | +| `+0x18C` | 8 x float | Parameters — see below | + +There is one record per line of the sign. Jungle letters its two lines in different fonts +(`Young Itch AOE` then `Clunker AOE`); Space names the same font twice. + +Of the eight floats, indices 2, 3 and 4 are always in the range 0..1 and are the **text colour** +— jungle's first line is `0.40, 0.87, 0.31`, a green, and the four parks' values are distinct and +park-appropriate. The remaining five are not established. Index 0 ranges 1.75..13.5 and index 7 +sits between 248 and 360, which would suit a size and an angle, but neither has been confirmed. + +### The pixel data at `0x03C5` + +16384 bytes, which is 64x64 at 32 bits per pixel. It decodes to a small tiling pattern rather +than to anything resembling the sign, so while it is clearly image data, its dimensions and +purpose are not established. It is **not** the sign artwork — that is the image body below. + +## The image body + +Everything from `0x43C5` is a [`.wct`](/formats/texture/) version 4 image with its header fields +rearranged: four quantisation scales, two chunk sizes, then the colour and alpha chunks, each +wrapped in the same `BILZ` zlib container a `.wct` uses. A decoder can be handed these directly. + +The image is **256x128**, decoded at an aligned size of 256 and with the half-scale path (the +`FullScale` flag clear). That gives the expected chunk sizes: + +- colour inflates to 98304 = `256*256` (Y) + `128*128` (Cb) + `128*128` (Cr) +- alpha inflates to 65536 = `256*256` + +The alpha channel is uniform and opaque in all four lobby signs — it compresses to 190 bytes in +every one — so the board is a solid picture, not a cut-out. + +**The artwork is a blank board.** Jungle's is cracked bark, fantasy's a mint wall, hallow's a +framed maroon panel, space's a starfield. No lettering is baked in; the park name is composited +on top at runtime using the fonts named above. The clearest evidence is that the colour chunk's +*compressed* size tracks the length of the park name — 5,607 bytes for "Space" (5 characters), +7,126 for "Halloween" (9), 11,760 for "Lost Kingdom" (12) and 14,606 for "Fantasy Island" (14) — +even though every file inflates to the same 98304 bytes. + +## How the board maps onto the model + +The model draws the board as **two 128x128 panels side by side**, using materials `sign1` and +`sign2`, each taking a full 0..1 UV range. `sign1` is the left half of the sheet and `sign2` the +right. Each material appears twice, once per face, mirrored, so the sign reads correctly from +either side. + +Laying a two-line name out across the whole 256x128 sheet and then cutting it down the middle is +what puts "Lost" above "Kingdom" on the assembled board. + +## Where the name comes from + +Each park has a script at the root of `lobby.wad`, named after the park, holding a single +`ISLAND()` line whose fourth quoted field is the sign text: + +``` +ISLAND(0,"data\lobby\terrain","jun_isle","jun_gate","Lost Kingdom",90.0,12.5) +``` + +The others read `"Fantasy"`, `"Halloween"` and `"Space"`. The same line also names the terrain +directory and the two models, and ends with two numbers that look like a heading and a size. From db1fc74a07f2a28e2dbaaa2d8394a43f6b402abc Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 18:39:36 -0400 Subject: [PATCH 08/14] Document UV animation duration and the not-found texture fallback Two things the format pages implied but did not state, both found while fixing the lobby islands in the engine repo. A UV channel has no start frame - it ramps from the beginning of the animation to the end frame in its duration table - so for a clip with no rotation or morph channels that table is the only thing saying how long the clip runs. 98 of the game's 1151 animation files are exactly that, including the Wonder Land and Halloween World islands, whose water is all they animate. A texture name that resolves to nothing is also not an error: the engine logs it and substitutes the first texture cache entry, which is loaded at startup from Data\Generic\defaulttexture\NotFound.tga. Hal_isle.MD2 depends on this - its sign names a "signgrab" texture that exists nowhere in the game. Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/models.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index 8e38f4e..fb8c244 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -155,6 +155,14 @@ directly. A material's `FrameOffset` of exactly 0 is a sentinel meaning "no texture", not a real offset - real offsets always start at the frame table's own offset. +A texture name that resolves to nothing is not an error. The engine searches two directories for +each name, logs `Could not load texture '%s' from '%s' or '%s'`, and then assigns the frame the +**first entry of the texture cache**, which the cache fills at startup with +`Data\Generic\defaulttexture\NotFound.tga` - a 64x64 brown noise tile (engine-confirmed). The +shipped data does rely on this: `Hal_isle.MD2`'s sign mesh names a `signgrab` texture that exists +nowhere in the game, and its frame draws as that brown tile rather than as anything the artists +authored. + The 8-byte frame table entries also begin with a **flags byte**: the engine tests bits `0x40` and `0x80` of byte 0 and propagates them into the model-wide flags at header 0x30 (engine-confirmed). @@ -533,6 +541,14 @@ out of 53 UVs. `(-1, -1)` over 100 frames, and 104 of the `Island` mesh's 298 UVs by the same delta - the shoreline foam lapping the beach, with the rest of the island held still. +An entry has no start frame: it always ramps from the beginning of the animation to the end frame +its duration table names. That makes the duration table the **only** statement of how long a +UV-only animation runs, which is worth saying outright, because rotation and vertex morph both +carry explicit frame indices and UV does not. `Fan_isleM1` and `Hal_isleM1` scroll water and do +nothing else; work an animation's length out from its rotation and morph keyframes alone and +those files span zero frames, so their water never moves. 98 of the game's 1151 animation files +are in that position. + ### Sequencing Nothing in the format says how a model's `M1`, `M2`, ... animations are ordered, when they From 462ca46f4daae69925da83acc21f9253b49c707d Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 19:37:35 -0400 Subject: [PATCH 09/14] Document the lobby scripts The plain-text files in lobby.wad that configure the island-selection screen - what each park is, what flies around it, and what its weather does. The useful part is that the vocabulary is closed: the parser strncmps each line against a table of twelve keywords contiguous in the executable at 0x00774ce0, and that is all a lobby script can say. The shipped scripts use seven of them, so without knowing the table it is easy to assume the rest of the lobby's behaviour is configured somewhere yet to be found. The other thing worth writing down is that LIGHTNING(n) is a probability mask rather than a count or a period - a strike is (n & random) == 1, so the chance is one in two-to-the-number-of-set-bits, and zero if n is even. Hallow's 63 is one frame in sixty-four. Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/lobby-scripts.md | 138 ++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/content/docs/formats/lobby-scripts.md diff --git a/src/content/docs/formats/lobby-scripts.md b/src/content/docs/formats/lobby-scripts.md new file mode 100644 index 0000000..619ecf7 --- /dev/null +++ b/src/content/docs/formats/lobby-scripts.md @@ -0,0 +1,138 @@ +--- +title: Lobby scripts (*.txt) +--- + +The lobby - the island-selection screen the game opens on - is configured by plain-text files in +`lobby.wad`. There is one for the lobby itself and one per park: + +| File | Describes | +| --- | --- | +| `lobby.txt` | The camera, the globe, and where each island sits | +| `jungle.txt` | Lost Kingdom | +| `fantasy.txt` | Wonder Land | +| `hallow.txt` | Halloween World | +| `space.txt` | Space Zone | + +They are short. `hallow.txt` in full: + +``` +ISLAND(2,"data\lobby\terrain","hal_isle","hal_gate","Halloween",180.0,38.0) +FLYINGMESH("data\lobby\terrain","bat",50,200.0,100.0,200.0,2.5) +LIGHTNING(63) +RAINY(1) +SKYCOLOUR(4,44,12) +``` + +## The vocabulary is closed + +The parser (`FUN_005e3210`) splits the file on newlines and `strncmp`s each line against a table +of twelve keywords, which sit contiguously in the executable at `0x00774ce0`: + +``` +ISLANDCAMERAPOSITION( GLOBERADIUSIN( GLOBERADIUSOUT( VERTICALOFFSET( +SPINRADIUS( SPINSPEED( ISLANDFOV( SKYCOLOUR( RAINY( LIGHTNING( +FLYINGMESH( ISLAND( +``` + +That table is the entire vocabulary - there is nothing else a lobby script can say. Anything else +the lobby does is a model animation or is hard-coded. Worth stating outright, because the shipped +scripts use only seven of the twelve and it is otherwise tempting to assume the missing effects +are configured somewhere else. + +Note that the keywords are matched **including** the opening bracket. `ISLAND` alone would also +match `ISLANDFOV` and `ISLANDCAMERAPOSITION`. + +Having found a keyword the parser scans forward to `(` and then reads field by field, stopping +each field at `,`, `"` or `)` as appropriate. Quoted fields are paths and names; everything else +is a number. + +## Per-park directives + +### `ISLAND(index, dir, isle, gate, name, heading, cameraHeight)` + +The park itself. `index` is its place in the lobby's running order, `isle` and `gate` are `.md2` +names under `dir`, `heading` is where the island sits around the lobby globe in degrees, and +`cameraHeight` is how far above the island the camera looks (12.5 for the jungle against 38 for +hallow). + +`name` is the park name, but only the jungle's is what the player is shown - the other three are +`"Fantasy"`, `"Halloween"` and `"Space"` where the game displays Wonder Land, Halloween World and +Space Zone. The displayed names are not in the shipped data at all. + +### `FLYINGMESH(dir, model, count, x, y, z, scale)` + +A swarm of animated models flying around the island. The parser passes the count, the model, the +island's own position, the three floats as a volume to wander, the scale, and a hard-coded +`500.0` to the spawner. + +Only two parks use it: + +| Park | Flyers | +| --- | --- | +| jungle | 10 `bfly_PINK` and 10 `bfly_YELL`, scale 1.5 | +| hallow | 50 `bat`, scale 2.5 | + +The three volume floats are `200.0, 100.0, 200.0` in every use, so there is no per-park variation +in them, and they are in the original's globe space - islands there sit on a sphere of radius 475. + +### `SKYCOLOUR(r, g, b)` + +Three 0-255 components, stored as 16.16 fixed point. All four parks set one: jungle +`243,203,191`, fantasy `5,170,255`, hallow `4,44,12`, space `125,8,8`. + +### `RAINY(n)` + +A rain level. Only hallow sets it, to 1. + +### `LIGHTNING(n)` + +**A probability mask, not a count or a period.** Each frame the lobby tick draws a random number +and strikes when + +```c +(n & random) == 1 +``` + +A strike therefore needs bit 0 of `n` set and every other bit of the masked value clear, so the +chance per frame is `1 / 2^(bits set in n)` - and **never**, if `n` is even. Only hallow sets it, +to 63, which is six bits: one frame in sixty-four, or about one strike every two and a half +seconds at the 25fps the rest of the game's data assumes. + +The bolt runs from ground level to 500 units up, its top offset from its base by a small random +lean, somewhere near the island. A strike also raises a flag the renderer flashes on, and plays a +thunder sound. + +## Lobby-wide directives + +These appear only in `lobby.txt`: + +| Directive | Shipped value | Meaning | +| --- | --- | --- | +| `ISLANDCAMERAPOSITION(i, x, y)` | ten entries | Where island `i` sits. Only the first four are used. | +| `SPINRADIUS(n)` | 70 | The camera's distance from the island it is orbiting | +| `SPINSPEED(n)` | 0.02 | Orbit speed, per frame | +| `VERTICALOFFSET(n)` | 20 | How far above the island the camera orbits | +| `GLOBERADIUSOUT(n)` | 475 | Camera distance from the globe, zoomed out | +| `GLOBERADIUSIN(n)` | 200 | Camera distance from the globe, zoomed in | +| `ISLANDFOV(n)` | 100 | Field of view. Not degrees - see below. | + +Selecting a park in the original spins the globe until that island's `ISLAND()` heading faces the +camera, then pulls in from `GLOBERADIUSOUT` to `GLOBERADIUSIN`. + +## How the weather is scoped + +Rain, lightning and sky colour are **not** properties of a place. The lobby tick works out which +island is nearest the camera and then, every frame, copies that island's `SKYCOLOUR` into the +renderer's sky colour, its `RAINY` value into a single global rain level, and rolls its +`LIGHTNING` mask. There is one sky, one rain system and one bolt for the whole lobby; selecting a +park is what changes them. + +## Open questions + +- `ISLANDFOV(100)` is not a vertical field of view in degrees - read that way the islands become + specks in a bowed horizon. It means something else, or reaches the projection another way. +- `SPINSPEED(0.02)` read as radians per frame is half a radian a second at 25fps, which is much + brisker than the lobby appears to turn. +- The float globals the lightning bolt picks its ground position and lean from. +- What the hard-coded `500.0` passed to the flying-mesh spawner is (the same constant is the + bolt's height). From 24a635fb6942f41a380b8aea8612b72fa02cd334 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 19:55:13 -0400 Subject: [PATCH 10/14] Correct FLYINGMESH: the last float is speed, and document the flight Written up from the parser alone the last field looked like a scale, which is the obvious reading and the wrong one. The flying-mesh class stores it at +0x28 and its per-flyer update multiplies it into the step, so it is speed - and nothing in FLYINGMESH scales the model at all. Two more corrections from the same read: the three volume floats are the box's full size rather than half-extents (every point is centre + extent * (rand - 0.5), from the constants at 0x00702adc and 0x00702ae0), and the count is scaled by a detail percentage and clamped. Adds the flight behaviour itself, which is four lines - seek a random point in the box, lag toward it, retarget within about 22 units - plus how orientation is built, which pitches with the direction but never banks. And the lightning bolt's spread constants, which were an open question here. Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/lobby-scripts.md | 78 ++++++++++++++++++----- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/src/content/docs/formats/lobby-scripts.md b/src/content/docs/formats/lobby-scripts.md index 619ecf7..6971713 100644 --- a/src/content/docs/formats/lobby-scripts.md +++ b/src/content/docs/formats/lobby-scripts.md @@ -59,21 +59,67 @@ hallow). `"Fantasy"`, `"Halloween"` and `"Space"` where the game displays Wonder Land, Halloween World and Space Zone. The displayed names are not in the shipped data at all. -### `FLYINGMESH(dir, model, count, x, y, z, scale)` +### `FLYINGMESH(dir, model, count, x, y, z, speed)` -A swarm of animated models flying around the island. The parser passes the count, the model, the -island's own position, the three floats as a volume to wander, the scale, and a hard-coded -`500.0` to the spawner. - -Only two parks use it: +A swarm of animated models flying around the island. Only two parks use it: | Park | Flyers | | --- | --- | -| jungle | 10 `bfly_PINK` and 10 `bfly_YELL`, scale 1.5 | -| hallow | 50 `bat`, scale 2.5 | +| jungle | 10 `bfly_PINK` and 10 `bfly_YELL`, speed 1.5 | +| hallow | 50 `bat`, speed 2.5 | + +**The last float is speed, not scale**, which is the obvious reading and the wrong one. It is +stored at `+0x28` on each flyer, and the per-flyer update multiplies it into the step: + +```c +position += direction * flyer[0x28] * delta +``` + +Nothing in `FLYINGMESH` scales the model. Flying meshes are drawn at the size they were authored. + +The speed is in the same per-tick units as `SPINSPEED` and the camera lag - the delta the game +multiplies by is counted in ticks of 25fps rather than in seconds - so 1.5 is 37.5 units a second +and 2.5 is 62.5. + +**The three volume floats are the box's full size, not its half-extents.** Every random point the +original picks is + +```c +coord = centre + extent * (random01 - 0.5) +``` + +from the constants `1/2^30` at `0x00702adc` and `0.5` at `0x00702ae0`. The centre is the island's +own position, unshifted (the offset constant at `0x00702d40` is zero). They are `200.0, 100.0, +200.0` in every use - 200 wide, 100 tall, 200 deep in the original's Y-up axes - so there is no +per-park variation. + +The count is scaled by a detail percentage and clamped, so there is always at least one flyer and +never more than the script asked for. At full detail it is the script's number. + +#### Flight + +The swarm's tick builds flyers up over several frames rather than all at once: it makes one, then +keeps going with a seven-in-eight chance, so fifty bats take about six frames. + +Each flyer's update (`FUN_005d9b50`) is the whole behaviour: + +```c +wanted = normalise( target - position ) +direction = normalise( direction + (wanted - direction) * delta * 0.1 ) +position += direction * speed * delta + +if ( distanceSquared( position, target ) < 500 ) + target = randomPointInBox() +``` + +So: pick a random point in the box, lag toward it (never turning sharply), and on arrival - within +about 22 units, from the hard-coded `500.0` compared squared - pick another. There is no wander, +no boundary avoidance and no flocking. The box is respected because every destination is inside +it, not because anything pushes back at the edges. -The three volume floats are `200.0, 100.0, 200.0` in every use, so there is no per-park variation -in them, and they are in the original's globe space - islands there sit on a sphere of radius 475. +Orientation is built with the flight direction as forward and a sideways axis of +`normalise(dz, 0, -dx)` - note the zero in the up slot, at `0x00702a38` - so a flyer pitches with +its climb and dive but never banks into a turn. ### `SKYCOLOUR(r, g, b)` @@ -98,9 +144,10 @@ chance per frame is `1 / 2^(bits set in n)` - and **never**, if `n` is even. Onl to 63, which is six bits: one frame in sixty-four, or about one strike every two and a half seconds at the 25fps the rest of the game's data assumes. -The bolt runs from ground level to 500 units up, its top offset from its base by a small random -lean, somewhere near the island. A strike also raises a flag the renderer flashes on, and plays a -thunder sound. +The bolt runs from ground level to 500 units up. Its base lands within fifty units of the island +(`random01 * 100 - 50`, from `0x00702c94`) and its top leans by up to ten more +(`random01 * 20 - 10`, from `0x00702c8c`). A strike also raises a flag the renderer flashes on, +and plays a thunder sound. ## Lobby-wide directives @@ -133,6 +180,5 @@ park is what changes them. specks in a bowed horizon. It means something else, or reaches the projection another way. - `SPINSPEED(0.02)` read as radians per frame is half a radian a second at 25fps, which is much brisker than the lobby appears to turn. -- The float globals the lightning bolt picks its ground position and lean from. -- What the hard-coded `500.0` passed to the flying-mesh spawner is (the same constant is the - bolt's height). +- Whether the flying-mesh `500.0` and the lightning bolt's 500-unit height being the same number + is meaningful or a coincidence. From 87f53e93b7a6111539102af10f06d75cfb924603 Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 20:41:13 -0400 Subject: [PATCH 11/14] Correct what SKYCOLOUR actually does It does not paint the sky. The lobby's sky is the fantasy level's textured one, hard-coded and never swapped; SKYCOLOUR floods the sky mesh's 16x16 vertex colour ramp, which modulates that texture. It is gated on SKYQUALITY > 1 and the hardware path, and its red channel eases toward the blue target - so no park gets the colour it asks for. Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/lobby-scripts.md | 62 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/content/docs/formats/lobby-scripts.md b/src/content/docs/formats/lobby-scripts.md index 6971713..d27ce19 100644 --- a/src/content/docs/formats/lobby-scripts.md +++ b/src/content/docs/formats/lobby-scripts.md @@ -126,6 +126,60 @@ its climb and dive but never banks into a turn. Three 0-255 components, stored as 16.16 fixed point. All four parks set one: jungle `243,203,191`, fantasy `5,170,255`, hallow `4,44,12`, space `125,8,8`. +**It does not paint the sky.** The lobby's sky is a textured one, loaded once and never +swapped: `FUN_005d8b50` hands the hard-coded path `Data\Levels\fantasy` to the sky loader, so +every island in the lobby sits under Wonder Land's sky - `sky\sky_cyl.tga` for the dome and +`sky\sky.tga` for the cloud layers over it. Both are blue. + +What `SKYCOLOUR` reaches is that sky's **vertex colours**. The sky mesh is a 16x16 grid, and its +colours come from a 256-entry ramp at `skyObject + 0xf60`, normally a 16x16 downsample of +`sky\sky_rgb.tga` built by `FUN_00585ce0`. Each lobby frame `FUN_005d96c0` floods that ramp with +a single colour, and the sky texture is modulated by it. So `SKYCOLOUR` is a tint over a blue +sky, never a replacement for it. + +It is also conditional. The flood only uses `SKYCOLOUR` when both + +- `SKYQUALITY > 1` - the number of cloud layers, from the detail preset. `low.sam` sets 1, + `med.sam` 2, `high.sam` 4. **On Low detail the tint is never applied.** +- the hardware rendering path is active (`DAT_0078d8d8 == 1`, set by `FUN_0044de20`). + +Otherwise the ramp is flooded with the current fog colour instead, which the lobby sets to a +fixed `0xFF44DDFF` - light blue. + +#### The red channel is bugged + +The flood eases the colour rather than snapping to it, one sixteenth of the remaining distance +per frame, from separate "current" fields at `+0x44/+0x48/+0x4c`. Red is read from the wrong +slot: + +``` +5d96e1: mov edx,[ecx+0x50] ; the red target - only ever compared, never used +... +5d96fc: mov edx,[ecx+0x58] ; the blue target +5d9702: mov eax,edx +5d9704: sub eax,esi ; esi is the *current red* +5d9706: sar eax,0x4 +5d9709: add eax,esi +5d970b: mov [ecx+0x44],eax ; stored back as red +``` + +So red converges on the blue the script asked for, and no park gets the colour it wrote: + +| Park | Asked for | Actually gets | +| --- | --- | --- | +| jungle | 243, 203, 191 | 191, 203, 191 | +| fantasy | 5, 170, 255 | 255, 170, 255 | +| hallow | 4, 44, 12 | 12, 44, 12 | +| space | 125, 8, 8 | 8, 8, 8 | + +Multiplied onto a blue sky, jungle's near-neutral grey and fantasy's magenta both leave it +looking blue; hallow and space take it to nearly black. + +The bug has a second effect. The ease only runs while current and target differ, and the +comparison tests red against `+0x50` while the ease drives it toward `+0x58` - so for any park +whose red and blue differ, which is all four, the two never agree and the ease restarts every +frame forever. The "converged" branch, which would fall back to the fog colour, is unreachable. + ### `RAINY(n)` A rain level. Only hallow sets it, to 1. @@ -170,10 +224,14 @@ camera, then pulls in from `GLOBERADIUSOUT` to `GLOBERADIUSIN`. Rain, lightning and sky colour are **not** properties of a place. The lobby tick works out which island is nearest the camera and then, every frame, copies that island's `SKYCOLOUR` into the -renderer's sky colour, its `RAINY` value into a single global rain level, and rolls its +lobby camera's target colour, its `RAINY` value into a single global rain level, and rolls its `LIGHTNING` mask. There is one sky, one rain system and one bolt for the whole lobby; selecting a park is what changes them. +Fog is separate and is not driven by any of this. `FUN_005d8b50` sets the lobby's fog colour to a +constant `0xFF44DDFF` with a near of 50 and a far of 300, and the island view then turns fog +**off** outright (`FUN_005d9690`, clearing bit `0x2000`). Only the globe views run with it on. + ## Open questions - `ISLANDFOV(100)` is not a vertical field of view in degrees - read that way the islands become @@ -182,3 +240,5 @@ park is what changes them. brisker than the lobby appears to turn. - Whether the flying-mesh `500.0` and the lightning bolt's 500-unit height being the same number is meaningful or a coincidence. +- What `DAT_0078d8d8` is set from. It gates the sky tint on the hardware path, but the value + itself is written through a pointer rather than by name, so which option writes it is unproven. From 70b6398e96310f0c1959078df90e4457cef2994c Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 21:28:07 -0400 Subject: [PATCH 12/14] Document the lobby sky's geometry and which layer SKYCOLOUR reaches Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/lobby-scripts.md | 52 ++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/content/docs/formats/lobby-scripts.md b/src/content/docs/formats/lobby-scripts.md index d27ce19..bba841a 100644 --- a/src/content/docs/formats/lobby-scripts.md +++ b/src/content/docs/formats/lobby-scripts.md @@ -131,11 +131,53 @@ swapped: `FUN_005d8b50` hands the hard-coded path `Data\Levels\fantasy` to the s every island in the lobby sits under Wonder Land's sky - `sky\sky_cyl.tga` for the dome and `sky\sky.tga` for the cloud layers over it. Both are blue. -What `SKYCOLOUR` reaches is that sky's **vertex colours**. The sky mesh is a 16x16 grid, and its -colours come from a 256-entry ramp at `skyObject + 0xf60`, normally a 16x16 downsample of -`sky\sky_rgb.tga` built by `FUN_00585ce0`. Each lobby frame `FUN_005d96c0` floods that ramp with -a single colour, and the sky texture is modulated by it. So `SKYCOLOUR` is a tint over a blue -sky, never a replacement for it. +What `SKYCOLOUR` reaches is that sky's **vertex colours** - and only some of them. + +The sky is drawn as a horizon band plus up to four cloud layers on a 16x16 grid above it. The +grid's vertex colours come from a 256-entry ramp, one copy per layer, at `skyObject + 0xf60`, +`+0x1360`, `+0x1760` and `+0x1b60` - normally a 16x16 downsample of `sky\sky_rgb.tga` built by +`FUN_00585ce0`, a gradient from pale cyan to deep blue. Each lobby frame `FUN_005d96c0` floods +**the first copy only** with a single colour, and that colour is `SKYCOLOUR`. + +So it reaches exactly one of the four cloud layers. The other three keep the gradient, and the +horizon band is drawn at plain white with no ramp at all - which is why the horizon stays blue +whatever a park asks for. + +### The sky's geometry + +Everything below is read out of `FUN_00584ef0` (the sky object's init), `FUN_00585720` (which +builds the mesh) and `FUN_005863c0` (the draw). + +| | Value | Where from | +| --- | --- | --- | +| Cloud grid | 16x16 vertices over 2400 units | `+0x21e0`/`+0x21e4` = `0x960` | +| Grid centre | `(0, 180, 0)` in the lobby, height 300 elsewhere | `FUN_00585690( 0, 180, 0 )` | +| Droop | height is `centre - 0.2 * radius` | `0x00701f54` | +| Band radius | 0.6 of the grid's half width, so 720 | `0x00701f58` | +| Band rings | radii 1, 0.98, 0.96, 0.84 of that | `0x00701f64`.. | +| Band height | +/- 36, meeting the dome exactly at its radius | derived | +| Band columns | 9 per half turn, texture wrapping once per half | draw loop | + +`sky_cyl.tga` is the same gradient stacked twice vertically, which is why each half of the band +gets one copy of it. The lobby picks a different V band from a park - a whole copy, with the top +row repeated at the bottom, mirroring the gradient below the horizon. + +The four cloud layers share the grid and differ only in these: + +| Layer | Tiling | Scroll U | Scroll V | Opacity | +| --- | --- | --- | --- | --- | +| 0 | 7pi/112 | 0.0008 | 0.00009 | 0xCF | +| 1 | 6pi/112 | 0.0013 | 0.00006 | 0x97 | +| 2 | 5pi/112 | 0.0018 | 0.00003 | 0x5F | +| 3 | 4pi/112 | 0.0023 | 0 | 0x27 | + +Scroll is per tick at 25fps. `sky.tga` is flat white with its clouds entirely in the alpha +channel, so what a layer paints is its ramp colour and the texture only says where. + +Finally, `DAT_008bcbc8 & 0x2000000` - set by the lobby's island view and by nothing else - draws +every cloud layer a second time at `(-x, -y, -height)`. The dome on its own is a disc that stops +where it would fall below the horizon band; its reflection is a bowl rising to meet that edge, +and the two close the sky into a lens with the camera inside. It is also conditional. The flood only uses `SKYCOLOUR` when both From 1d589252335a9bb14b00f0ba1b8763bc9490178b Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Wed, 9 Sep 2026 23:42:08 -0400 Subject: [PATCH 13/14] Document the material flag word, and what bit 0x2 is worth The flags word was listed as an open question. It is the first four bytes of the 8-byte frame table entry - a material's FrameOffset points there, so the two are the same bytes reached from either direction, and its low byte is the same one whose 0x40 and 0x80 the engine already propagates into header 0x30. That also means the word belongs to the texture *as used by this model* rather than to the material: 62 of the 2,990 textures referenced across the game are flagged one way by one model and another by another. Measured over all 12,951 material uses in the game's 841 material-bearing mesh files. Only the low byte is ever used - nothing anywhere exceeds 0x73, and just 15 distinct values occur. Bit 0x1 is set on every material in the game and so says nothing. 0x10 and 0x20 are not a pair, occurring alone 1,209 and 1,246 times against 2,640 together. 0x40 appears on 159 materials and 0x80 on none. Bit 0x2 marks a material drawn see-through, and the honest version of that is one-directional: when it is set the texture has an alpha channel 87% of the time, but when a texture has one the bit is set only 56% of the time. It is an authoring decision rather than a restatement of the texture's format, and the game's ground and path art is full of 32-bit tiles drawn opaque. Compare against the texture header's alpha-channel byte and not its bit depth - sen_ant1 is stored 32-bit and declares no alpha channel. Noted with it: the lobby is a much tidier corner of the data than the game as a whole (224 of 232 agree there), and the bit does not separate cut-out art from blended art, which anything rendering from it has to handle. Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/models.md | 77 ++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/content/docs/formats/models.md b/src/content/docs/formats/models.md index fb8c244..4a34056 100644 --- a/src/content/docs/formats/models.md +++ b/src/content/docs/formats/models.md @@ -167,10 +167,14 @@ The 8-byte frame table entries also begin with a **flags byte**: the engine test and `0x80` of byte 0 and propagates them into the model-wide flags at header 0x30 (engine-confirmed). +That byte is the same one a material reads as its flags word - a material's `FrameOffset` points +at this entry, so the two are the same bytes reached from either direction. See +**Material flags** below for what is known of the individual bits. + #### Open questions -- The remaining 7 bytes of each frame table entry, and which bits of the two flags fields mean - what. +- The remaining 7 bytes of each frame table entry, and the frame data table's own flags field at + 0x00 beyond its bit 0 being tested. ### Mesh table @@ -293,9 +297,76 @@ A material with a non-zero frame offset also has a 4-byte flags word, read direc frame offset in the file (i.e. the frame offset does double duty: it locates both the frame table entry for the texture name, and a flags value sitting at that same byte offset). +### Material flags + +Because that word is read *at* the frame offset, it is the first four bytes of the 8-byte frame +table entry described under **Textures** - and its low byte is the same "flags byte" whose `0x40` +and `0x80` the engine propagates into the model-wide flags at header 0x30. + +That placement means **the word belongs to the texture as used by this model**, not to the +material alone: two materials in the same model naming the same texture necessarily share it. +Two *different* models naming the same texture need not, and 62 of the 2,990 textures referenced +across the game are flagged one way by one model and another way by another. + +Measured across the 12,951 material uses in the game's 841 material-bearing mesh files: + +| Bit | Uses | What is known | +| ------ | ----- | --------------------------------------------------------------------------------- | +| `0x01` | all | Set on every material in the game; carries no information | +| `0x02` | 2,621 | Marks a material meant to be drawn see-through - see below | +| `0x10` | 3,849 | Unknown. Independent of `0x20` | +| `0x20` | 3,886 | Unknown. Independent of `0x10` | +| `0x40` | 159 | Propagated into the model-wide flags at header 0x30 (engine-confirmed) | +| `0x80` | 0 | Also propagated into header 0x30 (engine-confirmed), but no shipped material sets it | + +The word never exceeds `0x73` anywhere in the game - only the low byte is ever used, and just 15 +distinct values occur across all 12,951 uses. `0x10` and `0x20` are **not** a pair: they occur +alone 1,209 and 1,246 times respectively, and together 2,640 times. + +#### Bit 0x02 - drawn see-through + +This is the only bit whose meaning is established, and it is established from the data rather +than from the engine: nothing in the decompile reads it back where a render state is chosen. + +It correlates with the texture declaring an alpha channel, but **only in one direction**. Of the +12,773 material uses whose texture could be resolved: + +| | texture has alpha | texture has none | +| ------------------------ | ----------------- | ---------------- | +| **bit set** | 2,247 | 335 | +| **bit clear** | 1,780 | 8,411 | + +So when the bit is set the texture has an alpha channel 87.0% of the time, but when a texture has +an alpha channel the bit is set only 55.8% of the time. It is not a restatement of the texture's +format - it reads as an authoring decision, "draw this one see-through", and a great deal of the +game's 32-bit art is deliberately drawn opaque. The largest groups carrying alpha without the bit +are ground and path tiles: `m_grass1`, `m_grass2`, `jfl_cnr2`, `jpa_que1`. + +Compare against the texture header's **alpha-channel byte and not its bit depth**. The two are +different fields and they disagree: `sen_ant1` is stored 32-bit but declares no alpha channel. + +> The percentages above carry a small caveat: 100 of the 3,385 distinct `.wct` names in the game +> appear in more than one WAD with different alpha-channel bytes, so a name-keyed lookup cannot +> be exact for those. + +In the lobby - the one scene OpenTPW currently renders - the correlation is far tighter. Of the +232 material uses whose texture ships in `lobby.wad`, 224 agree. Six of the eight that disagree +are textures carrying alpha and drawn opaque anyway; the other two set the bit over a texture +with no alpha channel at all, one of them the lobby's own sea surface. Treating the bit as "draw +see-through" is what makes the islands' shoreline ripple rings and the Space island's antenna +cone render correctly. + +What the bit does **not** distinguish is cut-out art from genuinely blended art. Most of what +carries it in the lobby is cut-out foliage - palm fronds, grass blades, bushes, the bats and +butterflies - which wants its alpha *tested*; only the ripple rings, the sea and the antenna cone +are true gradients. A renderer acting on this bit has to serve both. + #### Open questions -- The two unidentified 2-byte fields, and the meaning of the flags word. +- The two unidentified 2-byte fields in the material record, and the 4-byte field at its end. +- What `0x10` and `0x20` select, and what `0x40` means beyond being propagated to header 0x30. +- Whether anything in the format distinguishes an alpha-tested cut-out material from a blended + one, or whether the original engine drew both the same way. ### Normals From d73a627fb236609f574e65ef246965c55a066c0f Mon Sep 17 00:00:00 2001 From: Alexah Woods Date: Thu, 10 Sep 2026 00:11:28 -0400 Subject: [PATCH 14/14] Correct the .sgn font record, and document where the ink really is The font record is not two 64-byte name fields and eight floats. It is a 64-byte display name, a 260-byte TrueType file name, two 4-byte fields and a 60-byte Windows LOGFONTA - the original letters its signs with GDI, so it stores a LOGFONT rather than a size and weight of its own. That is also why the display name looks like it occurs twice: the second one is LOGFONTA.lfFaceName, at +28 within the structure. The three floats this page read as a text colour sit past the end of that LOGFONT. They are not a colour. The reading is an easy one to reach and it survives a casual check, because three of the four parks have dark boards and show up in almost any colour - only Fantasy gives it away, its board being pale mint and those floats being very nearly the same mint. The real ink is a four-byte block per line following both font records: red, green, blue, opacity. Three steps in the engine fix that order - the loader reads the four bytes singly into consecutive bytes of its sign object, the renderer passes them to the compositor with the fourth first, and the surface they land in is packed ARGB4444. The opacity is a real blend rather than a threshold. Also here: the first seventeen bytes of the header broken out, including the two line modes, which matter because mode 2 merges both glyph masks and inks them together - so the second line's own bytes are never read and both words come out in the first line's colour. Jun_isle and Spa_gate do that; Fan_gate and Hal_isle ink each line separately. Co-Authored-By: Claude Opus 5 --- src/content/docs/formats/sgn.md | 92 +++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/src/content/docs/formats/sgn.md b/src/content/docs/formats/sgn.md index 8f78a3f..c9b158c 100644 --- a/src/content/docs/formats/sgn.md +++ b/src/content/docs/formats/sgn.md @@ -24,12 +24,22 @@ fantasy and space — so a lookup has to try both rather than assume either. The header is a fixed size: the image body starts at `0x43DD` in all five files. +> Rows marked **(engine-confirmed)** were checked against the original game's own reader and +> sign compositor rather than inferred from the shipped files alone — the reader loads the header +> field by field in file order, which is what fixes the sizes and the boundaries below. + | Offset | Size | Description | | --- | --- | --- | -| `0x0000` | 17 bytes | Unknown | +| `0x0000` | 4 bytes | Version — the engine requires `> 99`; all five files are `101` (engine-confirmed) | +| `0x0004` | 4 bytes | Unknown — `0` in all five | +| `0x0008` | 1 byte | Unknown — `1` in all five | +| `0x0009` | 4 bytes | **Line 0 mode** — see **Line modes** below (engine-confirmed) | +| `0x000D` | 4 bytes | **Line 1 mode** (engine-confirmed) | | `0x0011` | 436 bytes | Font record 0 | | `0x01C5` | 436 bytes | Font record 1 | -| `0x0379` | 76 bytes | Unknown | +| `0x0379` | 20 bytes | **Line 0 ink** — see **Line ink** below (engine-confirmed) | +| `0x038D` | 20 bytes | **Line 1 ink** (engine-confirmed) | +| `0x03A1` | 36 bytes | Unknown — three ints (`16`, `128`, `4`) then six 4-byte entries | | `0x03C5` | 16384 bytes | 32-bit pixel data — see below | | `0x43C5` | 4 x float | Y, Cb, Cr and A quantisation scales (`6, 10, 2, 6` in all five files) | | `0x43D5` | 4 bytes | Colour chunk size | @@ -41,17 +51,81 @@ The header is a fixed size: the image body starts at `0x43DD` in all five files. | Offset | Size | Description | | --- | --- | --- | | `+0x000` | 64 bytes | Display name, NUL-padded ASCII (e.g. `Young Itch AOE`) | -| `+0x040` | 64 bytes | TrueType file name (e.g. `YOUNIA__.TTF`) — the file itself is in `fonts.wad` | -| `+0x168` | 64 bytes | Display name again | -| `+0x18C` | 8 x float | Parameters — see below | +| `+0x040` | 260 bytes | TrueType file name (e.g. `YOUNIA__.TTF`) — the file itself is in `fonts.wad` | +| `+0x144` | 4 bytes | Unknown | +| `+0x148` | 4 bytes | Unknown | +| `+0x14C` | 60 bytes | A Windows **`LOGFONTA`** (engine-confirmed) | +| `+0x188` | 44 bytes | Read by a separate helper; contents not established | There is one record per line of the sign. Jungle letters its two lines in different fonts (`Young Itch AOE` then `Clunker AOE`); Space names the same font twice. -Of the eight floats, indices 2, 3 and 4 are always in the range 0..1 and are the **text colour** -— jungle's first line is `0.40, 0.87, 0.31`, a green, and the four parks' values are distinct and -park-appropriate. The remaining five are not established. Index 0 ranges 1.75..13.5 and index 7 -sits between 248 and 360, which would suit a size and an angle, but neither has been confirmed. +The original letters its signs with GDI, so it stores a `LOGFONT` rather than a size and weight of +its own. That structure is why the display name appears to occur a second time part-way through +the record: `LOGFONTA.lfFaceName` sits at `+28` within it, which is `+0x168` from the record +start. Its `lfHeight` is the usual negative character height (`-144` for jungle's first line). + +> **The floats in the tail are not a colour.** Reading the record as two 64-byte name fields +> instead gives eight tidy floats at `+0x18C`, three of which always land in `0..1` and look +> convincingly like a text colour — jungle's are `0.40, 0.87, 0.31`, a green. They are not. That +> offset falls past the end of the `LOGFONT`, inside the 44-byte tail above, and the values belong +> to whatever the helper reads there. +> +> The trap is worth spelling out because the wrong reading survives a casual check: three of the +> four parks have dark boards, so lettering them in some wrong colour still shows up. Only Fantasy +> gives it away — its board is painted pale mint and those floats are very nearly the same mint, +> so its name comes out invisible. The real ink is below. + +### Line ink + +Each line's colour is a four-byte block sitting after both font records, followed by four more +4-byte fields that are not yet identified — 20 bytes per line. A line whose mode is `0` is absent +and contributes no block at all, though no shipped sign does that. + +| Offset | Size | Description | +| --- | --- | --- | +| `+0x00` | 1 byte | Red | +| `+0x01` | 1 byte | Green | +| `+0x02` | 1 byte | Blue | +| `+0x03` | 1 byte | Opacity | +| `+0x04` | 4 x 4 bytes | Unknown — an int, a float, then two more ints | + +Three steps in the engine fix that ordering, and none of it has to be guessed. The loader reads +the four bytes **singly** into consecutive bytes of its sign object. The renderer hands them to +the compositor with the fourth byte first and the other three after it. The compositor walks the +glyph's coverage mask and moves each board pixel that fraction of the way toward the three +channels, writing them into bytes 1, 2 and 3 of a pixel whose byte 0 is the coverage — and that +same function later packs the buffer as **ARGB4444**, which is what makes byte 1 red rather than +blue. + +The opacity is a genuine blend and not a threshold, so a line set below full strength tints the +board and lets the artwork show through the lettering. + +What the four lobby signs ask for: + +| File | Mode | Line 0 | Line 1 | +| --- | --- | --- | --- | +| `Fan_gate.sgn` (Fantasy) | 1 | `#808000` olive, 67% | `#808000` olive, 66% | +| `Hal_isle.sgn` (Hallow) | 1 | `#00FF00` green, 79% | `#00FF00` green, 84% | +| `Jun_isle.sgn` (Jungle) | 2 | `#000000` black, 60% | *(never read)* | +| `Spa_gate.sgn` (Space) | 2 | `#FF80FF` pink, 100% | *(never read)* | + +### Line modes + +The two 4-byte fields at `0x0009` and `0x000D` say how each line is laid down. `1` inks the line +on its own; `2` means the two lines are drawn as one. + +That distinction matters for colour. At mode `2` the engine maxes both glyph masks into a single +surface and then runs **one** colour over the result, so the second line's own four bytes are +never reached and both words come out in the first line's ink. `Jun_isle.sgn` and `Spa_gate.sgn` +are both mode `2`; `Fan_gate.sgn` and `Hal_isle.sgn` are mode `1` and ink each line separately. A +file whose two modes disagree takes a third path that nothing in the shipped data exercises. + +Mode `2` is also the likelier home of the six 4-byte entries at `0x03A1`. They are a constant +`FF DB FF 30` repeated in three of the four signs, but in `Jun_isle.sgn` they are six distinct +values that climb steadily (`00 2F 5C 80`, `00 31 58 78`, … `00 3F 58 75`) — which reads like a +small gradient or palette, and would explain why Lost Kingdom's flat black is not the whole story +of how its board looks. This is **not** established. ### The pixel data at `0x03C5`