Document the .md2 model/animation, .sgn park sign and lobby script formats - #1
Open
maexah wants to merge 14 commits into
Open
Document the .md2 model/animation, .sgn park sign and lobby script formats#1maexah wants to merge 14 commits into
maexah wants to merge 14 commits into
Conversation
…on 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.
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.
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.
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.
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.
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.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
✅ Deploy Preview for stellular-meerkat-3c2a02 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three new format pages, written up from the shipped game data and checked against the original engine where it could settle a question. Nothing existing is modified — all three files are new.
Pages
Models (
.md2) — the static mesh layout, the node hierarchy (a model is a tree, and ignoring that piles child meshes at the origin), and the keyframe animation format that shares the same extension. Three channel kinds are decoded: per-vertex morph, per-mesh quaternion rotation, and UV scrolling. Also the material flag word, measured across all 12,951 material uses in the game's 841 material-bearing mesh files.Park signs (
.sgn) — the board artwork, the fonts, and the per-line ink. The record turns out to hold a WindowsLOGFONTA, because the original letters its signs with GDI.Lobby scripts — the per-park scripts in
lobby.wad:ISLAND,FLYINGMESH, weather andSKYCOLOUR.On confidence
Rows marked (engine-confirmed) were checked against the original's own loaders rather than inferred from the files alone — where the engine reads a field in file order, that fixes the sizes and boundaries. Everything else is measured across the full data set (2,123
.md2files, all five.sgn), and anything still unresolved is called out as an open question rather than guessed at.Two findings are worth flagging because the obvious reading is the wrong one:
The
.sgnfont record's trailing floats are not a text colour. Reading the record as two 64-byte name fields yields eight tidy floats, three of which sit in0..1and look exactly like a colour. They aren't — that offset falls past the end of theLOGFONT. The real ink is a four-byte block per line after both records, and three separate steps in the engine pin the channel order as red, green, blue, opacity. The wrong reading survives a casual check because three of the four parks have dark boards; only Fantasy gives it away, its board being pale mint and those floats being very nearly the same mint.The
.md2material bit0x2is one-directional. It correlates with the texture declaring an alpha channel, but only one way: when set, the texture has alpha 87% of the time; when a texture has alpha, the bit is set only 56% of the time. It is an authoring decision ("draw this see-through"), not a restatement of the texture format — and it does not separate cut-out art from genuinely blended art, which anything rendering from it has to handle.Happy to split this into separate PRs per page if that is easier to review.