Skip to content

Document the .md2 model/animation, .sgn park sign and lobby script formats - #1

Open
maexah wants to merge 14 commits into
OpenTPW:masterfrom
maexah:docs/md2-sgn-and-lobby-scripts
Open

Document the .md2 model/animation, .sgn park sign and lobby script formats#1
maexah wants to merge 14 commits into
OpenTPW:masterfrom
maexah:docs/md2-sgn-and-lobby-scripts

Conversation

@maexah

@maexah maexah commented Sep 10, 2026

Copy link
Copy Markdown

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 Windows LOGFONTA, because the original letters its signs with GDI.

Lobby scripts — the per-park scripts in lobby.wad: ISLAND, FLYINGMESH, weather and SKYCOLOUR.

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 .md2 files, 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 .sgn font 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 in 0..1 and look exactly like a colour. They aren't — that offset falls past the end of the LOGFONT. 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 .md2 material bit 0x2 is 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.

maexah and others added 14 commits September 8, 2026 16:28
…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>
@netlify

netlify Bot commented Sep 10, 2026

Copy link
Copy Markdown

Deploy Preview for stellular-meerkat-3c2a02 ready!

Name Link
🔨 Latest commit d73a627
🔍 Latest deploy log https://app.netlify.com/projects/stellular-meerkat-3c2a02/deploys/6aa22f618b272d0008ac9f26
😎 Deploy Preview https://deploy-preview-1--stellular-meerkat-3c2a02.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant