DramaticShapeVoxelMod archival mirror · read-only
74cc08f1bf860dc52f7f85d5b4d513378f385b99

dramatic stadium

DramaticShape · 2026-08-04 · parent 7b1ac9b1b6

dramatic stadium

Changed files

.gitignore                         |   39 +++
 CHANGELOG.md                       |  191 +++++++++++
 README.md                          |   61 ++++
 lib/BattleScene.lua                |  102 +++++-
 lib/ModSetting.lua                 |   83 ++++-
 lib/OverworldBattle.lua            |  135 +++++++-
 lib/Stadium.lua                    |  516 ++++++++++++++++++++++++++++
 lib/StadiumBuild.lua               |  709 +++++++++++++++++++++++++++++++++++++++
 lib/StadiumFragment.lua            | 1016 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/StadiumFx.lua                  |  436 ++++++++++++++++++++++++
 lib/StadiumInstall.lua             |  261 +++++++++++++++
 lib/StadiumMon.lua                 |  350 +++++++++++++++++++
 lib/StadiumPack.lua                |  553 ++++++++++++++++++++++++++++++
 lib/StadiumRig.lua                 |  443 ++++++++++++++++++++++++
 lib/StadiumRom.lua                 |  314 +++++++++++++++++
 lib/StadiumScreen.lua              |  218 ++++++++++++
 lib/StadiumStage.lua               |  330 ++++++++++++++++++
 lib/Voxel3D.lua                    |   24 ++
 lib/VoxelScene.lua                 |   28 ++
 main.lua                           |   20 +-
 manifest.json                      |    4 +-
 mod.card                           |   10 +-
 model_extract/README.md            |  168 ++++++++++
 model_extract/baseroms/.gitignore  |    5 +
 model_extract/baseroms/README.md   |   25 ++
 model_extract/pipeline/README.md   |   93 ++++++
 model_extract/pipeline/battle.py   |  122 +++++++
 model_extract/pipeline/build.py    |  345 +++++++++++++++++++
 model_extract/pipeline/effects.py  |  225 +++++++++++++
 model_extract/pipeline/fragment.py |  735 ++++++++++++++++++++++++++++++++++++++++
 model_extract/pipeline/glb.py      |  274 +++++++++++++++
 model_extract/pipeline/rom.py      |  120 +++++++
 tests/dramatic_shape_test.lua      |  256 +++++++++++++-
 tests/stadium_budget_test.lua      |  149 +++++++++
 tests/stadium_extract_test.lua     |  128 +++++++
 tests/stadium_pack_probe.lua       |  115 +++++++
 tests/stadium_shots.lua            |  294 ++++++++++++++++
 tools/stadium_pack.py              |  572 +++++++++++++++++++++++++++++++
 38 files changed, 9419 insertions(+), 50 deletions(-)

Diff

Diff truncated at 200.0 KB — fetch the full change with git show 74cc08f1bf after cloning.

diff --git a/.gitignore b/.gitignore
index c10636b..68bd0ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,42 @@ __pycache__/
 
 # agent worktrees and local scratch
 .claude/
+
+# ------- Pokemon Stadium data
+#
+# NONE OF THIS SHIPS, and none of it is in the repository. The battle models
+# are Pokemon Stadium's own data: what the mod carries is the READER for them
+# (lib/StadiumRom, StadiumFragment, StadiumFx, StadiumBuild) and the player
+# supplies the cartridge, exactly as this engine already asks them to supply
+# the Game Boy ROM it is a recompilation of.
+#
+# So the ROM itself, everything model_extract/pipeline extracts out of it, and
+# the packs tools/stadium_pack.py builds from those are all ignored. What IS
+# tracked is the pipeline, the notes, and the two READMEs that say where to
+# put a ROM.
+#
+# At runtime the packs are built on the player's own machine, on first run,
+# into the save directory -- never into the mod folder (see StadiumInstall).
+
+# the cartridge, wherever it is dropped, and the checksum note that comes
+# with one. The Zone.Identifier pattern has no colon in it on purpose: it is
+# an NTFS alternate data stream, and the separator reaches git as U+F03A
+# rather than as ':' -- so matching on the suffix alone is what actually works
+*.z64
+*.n64
+*.v64
+*Zone.Identifier
+model_extract/baseroms/**/checksum.md5
+
+# everything the pipeline extracts from it
+model_extract/glb/
+model_extract/js/
+model_extract/textures/
+model_extract/manifest.json
+model_extract/moves.json
+model_extract/viewer.html
+
+# and the packed models built from those -- the local oracle the Lua
+# extractor is diffed against (tests/stadium_extract_test.lua), rebuilt with
+# tools/stadium_pack.py whenever it is wanted
+assets/stadium/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 242770b..96668f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,196 @@
 # Changelog
 
+## 1.6.0
+
+### Added
+
+- **STADIUM battles: two new rungs on the 3D-BTL row.** The row that used
+  to read ON / OFF now reads **2D-3D / STADIUM A / STADIUM B / OFF**. 2D-3D
+  is what ON always was -- the fight staged on the map with the Game Boy's
+  own pics stood up on their tiles as quads. Both STADIUM rungs keep the
+  whole staging and replace those quads with the **Pokemon Stadium battle
+  models**: all 151 species, skinned, lit and animated, playing the
+  animation the move being used actually calls for.
+
+  **STADIUM A** stands them on the map, in the world's own light and
+  weather. **STADIUM B** stands them on two discs against the sky and draws
+  no map at all -- the Game Boy's own framing, staged rather than found.
+
+  B exists because the map does not always cooperate. Half of Kanto's
+  interiors are furniture, a cave floor can be nothing but two-cell
+  corridors, and some maps have nowhere a fight can be SEEN from a low
+  camera and are declined outright -- which drops the player back to the
+  flat battle screen with no warning. A carried stage has none of those
+  problems: it works on every map, at every step, and the framing is the
+  same every time. What it gives up is the thing A is for, which is
+  fighting somewhere real.
+
+  It is abstracted from the GROUND, not from the world. The sky behind the
+  discs is the hour's own -- gold at dusk, navy at midnight -- and a fight
+  in a cave or a shop is under that place's void and its own flat light, so
+  walking into Mt. Moon at night and starting a battle looks like Mt. Moon
+  at night.
+
+  The discs themselves are flat: one quad apiece, wearing a painted circle
+  that fades out at its rim, sized to the Pokemon standing on it. The fade
+  is an ordered dither baked into the texture's alpha rather than a smooth
+  ramp -- the scene shader discards under half alpha outright, so a ramp
+  would come out as a hard-edged circle -- which is the same trick the sky's
+  own bands use and reads as intended on a mode built out of visible texels.
+
+  Nothing else about the fight moves. The arena is picked the same way, the
+  camera is solved the same way, and the HUDs, the frosted text box, the
+  move animations and the depth of field are all exactly what 2D-3D draws
+  -- because every one of those is hung off the arena's CELLS rather than
+  off the pics. Swapping what stands on a cell changes nothing about where
+  the cell projects to, which is why this is a rung on the mode rather than
+  a second mode.
+
+  The animations are driven from the fight itself, at four seams: a move
+  plays the animation that species' own battle table names for that move
+  (the Stadium ROM's per-species move table, packed into the assets, keyed
+  by the same Gen 1 move id the engine's move defs already carry -- so DIG
+  really does put Diglett into the ground), damage plays the hit reaction,
+  fainting plays the faint and holds on its last frame, and a send-out
+  plays the entrance while the engine's own grow-out-of-the-ball scale
+  runs. Between all of that, the standby loop. The eyes blink and go
+  dizzy on their own counter, which is a texture animation glTF has no
+  channel for and the pack carries anyway. Charmander's tail flame and
+  Weezing's gas are there too, drawn additively over the body.
+
+  The models go through the mod's OWN vertex format and shader rather than
+  a path of their own, which is what earns them everything the rest of the
+  diorama has: the depth buffer decides what is in front of what, the sun
+  pass throws a shadow of the actual pose, the hour's tint lands on them,
+  the hit flash flattens them and the tilt-shift and depth of field see
+  them as part of the picture. Skinning is done on the CPU -- these are
+  674-vertex models and exactly two are ever on screen -- and once per
+  frame, so the sun, the camera and both VR eyes draw the same posed mesh.
+
+  It declines per POKEMON rather than per battle: a species whose pack is
+  missing, a substitute doll, or the trainer's own pic before the send-out
+  all fall back to the flat card, on that side alone, with the other side
+  keeping its model.
+
+  Stored as new values on the same key, with 2D-3D still first -- so a save
+  written before this update reads back as exactly the mode it was written
+  for.
+
+- **The models are built on your machine, from your own cartridge.** The
+  mod ships no Pokemon Stadium data and cannot: it is that game's. What it
+  ships is the READER.
+
+  Drop a Pokemon Stadium (US) ROM -- `.z64`, `.n64` or `.v64`, the byte
+  order is detected -- into a `baseroms/` folder beside the game, and the
+  first time it runs the 151 battle models are built out of it on a loading
+  screen, in about ten seconds, one species a frame. After that they sit in
+  the save directory and are read like any other asset. Until then the two
+  STADIUM rungs simply are not on the row: they are skipped rather than
+  shown and refused, because a setting that can be selected and then does
+  nothing is indistinguishable from a broken mod.
+
+  This is the same arrangement the engine already has for the Game Boy ROM
+  it is a recompilation of, and it is why nothing ROM-derived is in this
+  repository.
+
+  Doing it needed the whole extraction pipeline in Lua: the archive and its
+  Yay0 streams (`StadiumRom`), a geo-layout walk and an F3DEX2 interpreter
+  with six texture codecs and a bit-packed animation sampler
+  (`StadiumFragment`), the generated flame and gas stand-ins (`StadiumFx`),
+  and the bind-pose measurement and packer (`StadiumBuild`).
+
+  **Pure Lua, and deliberately so: it runs anywhere LOVE does, phones
+  included.** No FFI, no native helper, no second process, nothing outside
+  the standard library and stock LOVE calls. On desktop it is about seven
+  seconds and peaks at 68 MB of Lua heap, 32 MB of which is the cartridge
+  itself; the working set does not grow across the run.
+  `tests/stadium_budget_test.lua` measures both and fails if either stops
+  being bounded, because a transient peak a desktop shrugs off is what gets
+  a process killed on a phone. On Android the save directory is the app's
+  external-files folder, so `baseroms/` there is reachable over USB or a
+  file manager without root.
+
+- `tools/stadium_pack.py` now reads the ROM directly rather than a
+  pre-extracted tree, which makes it the ORACLE the Lua port is verified
+  against: `tests/stadium_extract_test.lua` runs both over the same
+  cartridge and requires all 151 packed files to come out **byte for byte
+  identical**. That is the only honest test of a port of that much numeric
+  code -- every rounding mode, iteration order and off-by-one shows up as a
+  differing byte, and there are thirty-four megabytes of them.
+
+- `ModSetting:setValue`, which sets a setting by its stored value rather
+  than by its place on the ladder, and `ModSetting:setGate`, which lets a
+  rung exist only when there is something behind it. 3D-BTL grew two rungs
+  in the middle of itself, and every caller that had counted to two would
+  otherwise have quietly meant something else afterwards.
+
+- `tests/stadium_shots.lua`, a shot driver for both rungs, with a control
+  mode and a pinned clock so two runs of it can be compared.
+
+### Fixed
+
+- **The eyes blinked several times a second.** A texture animation was
+  running on a clock of its own and wrapping on its own stream's length.
+  Rattata's standby loop is forty frames and its blink is five -- `6 8 7 8
+  6`, open through shut and back -- so that played it six times a second,
+  and every species with a short blink twitched the same way.
+
+  Two things were wrong, and the data says so plainly: 507 of the 691
+  animation/texture-animation pairs in the set are exactly the same length
+  as each other, which is what one shared frame counter looks like from the
+  outside. So the texture animation now rides the SKELETAL animation's own
+  frame, and it HOLDS its last entry past the end of its stream rather than
+  looping -- which is what the game's own sampler does (`func_80017540`).
+  Rattata now blinks once at the top of each idle loop and keeps its eyes
+  open for the other thirty-five frames.
+
+  The frame is not recomputed for the textures; `pose` stashes the one it
+  resolved and `textures` reads it, so the two cannot drift apart.
+
+- **The faint animation no longer plays while the HP bar is still
+  draining.** `onFaint` runs the instant HP reaches zero, but the engine
+  queues the visible collapse behind the move animation and the bar drain
+  -- and that drain takes real time, some two and a half seconds on a
+  full-health Pokemon. The model was therefore lying down while its own
+  health went on emptying above it.
+
+  The request is now recorded when HP hits zero and played when the bar
+  reaches zero, off the engine's own `shownHP` -- the same number the bar is
+  drawn from, so the two cannot drift. A faint that stops being owed in the
+  meantime (a revive, a battler replaced under us) is dropped rather than
+  fired late at whoever is standing there. Measured rather than eyeballed:
+  the shot driver's `DS_FAINT` case prints the frame the bar empties against
+  the frame the animation starts, and they are the same frame.
+
+- Two ordering bugs in the extraction pipeline that made its output
+  **non-reproducible**. The generated flame nodes were deduplicated through
+  a Python `set` of tuples containing strings, so their order moved with
+  `PYTHONHASHSEED` and Ponyta, Rapidash and Moltres got differently-seeded
+  flames on different runs of the same build; and texture registration
+  order came from iterating a `set` of ints, which is stable across runs
+  but is a CPython implementation detail rather than a fact about the data.
+  Both now go in order of appearance, which is the game's own order and the
+  one a Lua port can reproduce.
+
+### Changed
+
+- The packed format is now **DSM3**: textures are stored as raw RGBA rather
+  than PNG. An ImageData over those bytes costs nothing where a PNG costs a
+  decode on the frame a battle starts -- but the reason it was done is that
+  PNG means zlib, and Python's deflate and LOVE's need not agree byte for
+  byte, which would have made the extractor impossible to check against the
+  packer. Uncompressed pixels are the same pixels whoever wrote them. The
+  set is 34 MB rather than 24 MB, and it is generated locally rather than
+  shipped, so that is a trade worth making.
+
+### Known
+
+- Three species -- Exeggutor, Tangela and Magmar -- have standby loops that
+  are corrupt in the source extraction, throwing bones hundreds of units
+  off the body. The packer measures each species' loop against its own bind
+  pose and marks those three to hold the bind pose instead, so they stand
+  still and look like themselves rather than coming apart.
+
 ## 1.5.5
 
 ### Added
diff --git a/README.md b/README.md
index 4b2b42b..4b34c2c 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,67 @@ on the menu while the foe stands out on the map, and no angle holds a
 composition that is half frame and half world — so with it on, the shot holds
 the one the rig was solved for.
 
+## STADIUM battles
+
+The **3D-BTL** row has four rungs:
+
+| rung | the fight |
+| --- | --- |
+| **2D-3D** | staged on the map, with the Game Boy's own pics stood up on their tiles |
+| **STADIUM A** | staged on the map, with the Pokémon Stadium battle models |
+| **STADIUM B** | the same models on two discs against the sky, with no map drawn |
+| **OFF** | the engine's own battle screen |
+
+All 151 species, skinned and animated, playing the animation the move being
+used actually calls for — the Stadium ROM's own per-species move table, so
+**DIG** really does put Diglett into the ground. Damage plays the hit
+reaction, fainting plays the faint and holds there, a send-out plays the
+entrance, and between all of that the standby loop runs. Eyes blink and go
+dizzy; Charmander's tail flame and Weezing's gas are drawn over the body.
+
+**B is for the maps that cannot host a fight.** Half of Kanto's interiors are
+furniture, a cave floor can be nothing but corridors, and a map where neither
+Pokémon can be *seen* from a low camera is declined outright — which drops you
+back to the flat battle screen. B carries its stage, so it works everywhere
+and looks the same every time. It is abstracted from the ground, not from the
+world: the sky behind the discs is the hour's own, and a fight in a cave is
+under that cave's void and its own flat light.
+
+### Getting the models
+
+**They are not in this mod, and they cannot be** — they are Pokémon Stadium's
+data. What ships is the reader; you supply the cartridge, exactly as this
+engine already asks you to supply the Game Boy ROM it is a recompilation of.
+
+1. Put a **Pokémon Stadium (US)** ROM in a `baseroms/` folder beside the game.
+   `.z64`, `.n64` and `.v64` all work — the byte order is detected.
+   In a packaged build, `baseroms/` goes in the save directory; the mod logs
+   the exact path on startup when it cannot find one.
+2. Start the game. The 151 models are built out of the ROM on a loading
+   screen, in about ten seconds.
+3. The two STADIUM rungs appear on the 3D-BTL row.
+
+The built models live in the save directory, not in the mod folder, and are
+rebuilt automatically if the format changes or the ROM does. Until they exist
+the STADIUM rungs are simply not on the row — skipped rather than shown and
+refused, because a setting you can select that then does nothing is worse than
+one that is not there.
+
+**This works on mobile.** The extraction is pure Lua — no FFI, no native
+helper, no second process — so it runs anywhere LÖVE does. It peaks at about
+68 MB of Lua heap (32 MB of that the cartridge itself) with a working set that
+does not grow across the run, and `tests/stadium_budget_test.lua` fails if
+either stops being true. On Android the save directory is the app's
+external-files folder, so `baseroms/` there is reachable over USB or a file
+manager without root; the build is slower than a desktop's ~7 s but runs one
+species a frame behind the progress bar either way.
+
+Developers can pre-build them with `tools/stadium_pack.py`, which reads the
+same ROM through `model_extract/pipeline`. That path is also the *oracle*:
+`tests/stadium_extract_test.lua` runs it and the in-game Lua extractor over
+the same cartridge and requires all 151 packed files to come out byte for byte
+identical.
+
 ## VR
 
 The **VR** options row (OFF / ON, off by default) drives a PCVR headset
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 66f13ca..566731c 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -315,12 +315,24 @@ end
 
 local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
                            atlasFor, cards, token, host, neighbors,
-                           water, nbWater)
+                           water, nbWater, groundY)
   if not ShadowMap.available() then return end
   local sig = shadowSignature(state, arena, terrain, nbMesh, token)
   if not ShadowMap.stale(sig) then return end
   if not ShadowMap.begin(cx, cy, vw, vh) then return end
 
+  -- STADIUM B: the two discs are the only ground there is, so they are the
+  -- only thing the sun has to see besides the Pokemon themselves. Everything
+  -- below this is a map that is not in the shot.
+  if arena.discs then
+    pcall(function()
+      V.require("StadiumStage").cast(ShadowMap, arena, groundY or 0)
+    end)
+    pcall(function() V.require("Stadium").cast(ShadowMap) end)
+    ShadowMap.finish(sig)
+    return
+  end
+
   ShadowMap.draw(terrain, atlasFor(host), nil)
   for i, nb in ipairs(neighbors) do
     ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
@@ -354,6 +366,13 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
                    ShadowMap.snug(card.model))
   end
   ShadowMap.sprites(false)
+  -- and the STADIUM models, when that rung is the one running. NOT marked
+  -- as sprites: that flag exists so a flat card's cut-out is kept off the
+  -- water (see ShadowMap.sprites), and these are real geometry standing in
+  -- the world -- a Gyarados at the water's edge should put a Gyarados on
+  -- the water. Un-snugged for the same reason: snug is a bias for a card
+  -- rooted to the ground plane, and a model has thickness of its own.
+  pcall(function() V.require("Stadium").cast(ShadowMap) end)
 
   ShadowMap.finish(sig)
 end
@@ -363,6 +382,11 @@ end
 -- the one nearer the camera and therefore the one a mismatch would show up
 -- against.
 function BattleScene.groundY(map, arena)
+  -- STADIUM B's discs are carried, not found: their tops ARE the ground
+  -- plane, so there is no terrain height to read and reading one would put
+  -- the stage at whatever elevation the map happens to have at a spot the
+  -- fight is not actually happening on
+  if arena and arena.discs then return 0 end
   local ok, h = pcall(VoxelScene.groundAt, map,
                       arena.playerCell[1], arena.playerCell[2])
   return (ok and h) or 0
@@ -452,10 +476,25 @@ function BattleScene.render(state, arena, textures, token)
   -- moving, and a shimmer on background windows would fight the mons
   Voxel3D.glassGlint = 0
 
+  -- STADIUM B stands the fight on two carried discs against the sky, with no
+  -- map in the shot at all (see StadiumStage). Everything below still runs --
+  -- the letterbox, the camera solve, the sun, the pins, the tint, the depth
+  -- of field -- because none of it is about the terrain; what changes is
+  -- which geometry the two passes draw.
+  local discs = arena.discs and true or false
+
   -- shares the free-roam mode's request/evict bookkeeping, so a battle warms
   -- exactly the meshes walking around would have and nothing extra
-  local terrain, nbMesh, water, nbWater = prefetchArena(state, host)
-  if not terrain then return nil end
+  local terrain, nbMesh, water, nbWater
+  if discs then
+    -- and nothing is meshed for a disc fight, which is the other half of why
+    -- the rung works everywhere: there is no waiting for a chunk to build, so
+    -- the first frame of the first battle on a cold map is the finished shot
+    nbMesh, water, nbWater = {}, nil, {}
+  else
+    terrain, nbMesh, water, nbWater = prefetchArena(state, host)
+    if not terrain then return nil end
+  end
 
   local lx, ly, s, pw, ph = BattleScene.letterbox()
   if not (pw > 0 and ph > 0 and s > 0) then return nil end
@@ -487,7 +526,7 @@ function BattleScene.render(state, arena, textures, token)
   local cards = monCards(arena, groundY, textures)
   Voxel3D.camera = nil
   castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
-              cards, token, host, neighbors, water, nbWater)
+              cards, token, host, neighbors, water, nbWater, groundY)
 
   -- An opaque void either way. Outdoors the camera is low enough that the
   -- horizon is genuinely in frame, so it is sky; indoors it is the dark end
@@ -496,6 +535,18 @@ function BattleScene.render(state, arena, textures, token)
   -- geometry stops.
   local sky = VoxelScene.skyColor(host, 1)
              or VoxelScene.skyShade(INDOOR_SHADE, 1)
+  -- On STADIUM B the void is not a backdrop behind the scenery -- it IS the
+  -- scenery, because the map is not drawn. So outdoors it gets the full
+  -- treatment the free-roam camera gets: the banded gradient and the hour's
+  -- own sun or moon hanging in it (Voxel3D.beginScene paints those when the
+  -- sky it is handed carries bands). Indoors there is nothing to dress: a
+  -- room's void is one flat shade, which is what a room looks like past the
+  -- wall, and the disc fight in a cave is lit and coloured as that cave.
+  if discs and VoxelScene.skyColor(host, 1) then
+    local Sky = V.require("Sky")
+    local okDress, dressed = pcall(Sky.dress, sky)
+    if okDress and dressed then sky = dressed end
+  end
 
   Voxel3D.camera = cam
   -- the sun is turned up for the arena and put back afterwards, so the
@@ -528,6 +579,13 @@ function BattleScene.render(state, arena, textures, token)
     if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
       return
     end
+    if discs then
+      -- STADIUM B: the two platforms, and nothing else. No terrain, no
+      -- neighbouring maps, no water, no grass and no flowers -- see the
+      -- matching skips further down. What is behind them is the sky the
+      -- clear painted.
+      V.require("StadiumStage").draw(arena, groundY)
+    else
     Voxel3D.draw(terrain, atlasFor(host), nil)
     for i, nb in ipairs(neighbors) do
       Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
@@ -549,6 +607,7 @@ function BattleScene.render(state, arena, textures, token)
                      Mat4.translate(nb.ox, 0, nb.oy))
       end
     end
+    end
     -- The mons, standing on their tiles. Depth-tested like everything else,
     -- so a ledge or a tree between the camera and a Pokemon really is in
     -- front of it, and the alpha discard cuts the sprite's own outline out of
@@ -579,24 +638,35 @@ function BattleScene.render(state, arena, textures, token)
     end
     Voxel3D.glass(true)
     Voxel3D.seams(true)
+    -- and the STADIUM models, inside the same flash window and with the
+    -- same camera-ward pull, so a Pokemon standing on its tile still wins
+    -- the depth test against the tile. They manage the wireframe and the
+    -- glass mask around their own draws (StadiumRig), which is why this
+    -- sits outside the pair above rather than inside it.
+    local okStadium, stadiumErr = pcall(function()
+      V.require("Stadium").draw(BattleBillboard.PULL)
+    end)
+    if not okStadium then V.require("Stadium").report(stadiumErr) end
     if flashing then Voxel3D.flatten(nil) end
     -- grass and flowers ride the same camera-ward pull the free-roam pass
     -- gives them, measured against THIS camera's pitch rather than the
     -- orbit's -- there is no character here for them to overdraw, but the
     -- pull is also what keeps a tuft from z-fighting the floor it stands on
     local pull = VoxelScene.pull(math.max(pitch, 0.05))
-    Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
-    for _, nb in ipairs(neighbors) do
-      Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
-                   Mat4.translate(nb.ox, 0, nb.oy), pull)
-    end
-    local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
-    Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
-                 ShadowMap.snug(nil))
-    for _, nb in ipairs(neighbors) do
-      Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
-                   Mat4.translate(nb.ox, 0, nb.oy), fpull,
-                   ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
+    if not discs then
+      Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
+      for _, nb in ipairs(neighbors) do
+        Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
+                     Mat4.translate(nb.ox, 0, nb.oy), pull)
+      end
+      local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
+      Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
+                   ShadowMap.snug(nil))
+      for _, nb in ipairs(neighbors) do
+        Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
+                     Mat4.translate(nb.ox, 0, nb.oy), fpull,
+                     ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
+      end
     end
     local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
     if not canvas then return end
diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua
index 2bed605..33c44ad 100644
--- a/lib/ModSetting.lua
+++ b/lib/ModSetting.lua
@@ -47,6 +47,42 @@ local function indexOf(self, value)
   return 1
 end
 
+-- ------- rungs that are not always there
+--
+-- A ladder may carry a rung that cannot be selected right now -- STADIUM
+-- needs models built out of a ROM the player supplies, and until that has
+-- happened there is nothing behind the option. `gate` is asked per rung and
+-- decides whether it exists at all this frame.
+--
+-- Skipped rather than shown-and-refused, deliberately. A row that can be
+-- cycled onto and then does nothing is indistinguishable from a broken mod;
+-- a row that simply has fewer stops reads as the mod not offering something,
+-- which is the truth. What the player is missing, and how to get it, is said
+-- once in the row's help text instead of implied by a dead setting.
+--
+-- values[1] is never gated: it is the default and the fallback, so there is
+-- always at least one rung to land on.
+function ModSetting:setGate(gate)
+  self.gate = gate
+  return self
+end
+
+function ModSetting:allows(i)
+  if i == 1 or not self.gate then return true end
+  local ok, allowed = pcall(self.gate, self.values[i], i)
+  return (not ok) or allowed and true or false
+end
+
+-- How many rungs are live, for a caller that wants to know whether a row is
+-- worth showing at all.
+function ModSetting:rungs()
+  local n = 0
+  for i = 1, #self.values do
+    if self:allows(i) then n = n + 1 end
+  end
+  return n
+end
+
 -- What the player left it at last session. Read lazily rather than at load
 -- time: the loader fills modOptions before a mod runs, but reading through
 -- the API keeps this honest about where the value lives.
@@ -63,7 +99,13 @@ function ModSetting:read()
 end
 
 function ModSetting:get()
-  return self.values[self:read()]
+  local i = self:read()
+  -- a rung that was live when it was stored and is not now -- the player
+  -- moved the ROM, or opened the same save on another machine -- reads as
+  -- the default rather than as a mode with nothing behind it. The stored
+  -- value is left alone, so putting the ROM back restores their choice.
+  if not self:allows(i) then return self.values[1] end
+  return self.values[i]
 end
 
 function ModSetting:level()
@@ -91,8 +133,32 @@ function ModSetting:setIndex(i, game)
   return value
 end
 
+-- Set by the STORED VALUE rather than by its place on the ladder, for a
+-- caller that knows which setting it wants and not where it sits -- a
+-- preset, or an assertion. An unrecognised value lands on values[1], the
+-- same default indexOf answers everywhere else, so this can never leave a
+-- setting holding something the row cannot display.
+--
+-- Worth having as its own entry point because a ladder's ORDER is not a
+-- promise: 3D-BTL grew a third rung in the middle of itself (see
+-- OverworldBattle), and every caller that had counted to two would have
+-- silently meant something else afterwards.
+function ModSetting:setValue(value, game)
+  return self:setIndex(indexOf(self, value), game)
+end
+
+-- Step to the next rung that is actually live, in `dir`. Bounded by the
+-- ladder's length so a gate that refuses everything still terminates on
+-- values[1], which allows() never gates.
 function ModSetting:cycle(game, dir)
-  return self:setIndex(self:read() + (dir or 1), game)
+  dir = dir or 1
+  local n = #self.values
+  local i = self:read()
+  for _ = 1, n do
+    i = ((i + dir - 1) % n + n) % n + 1
+    if self:allows(i) then break end
+  end
+  return self:setIndex(i, game)
 end
 
 -- Adopt a value set from somewhere else (the mod manager's settings page,
@@ -109,7 +175,12 @@ function ModSetting:row()
   return {
     id = "DRAMATIC_SHAPE:" .. self.key,
     label = self.label,
-    value = function() return self_.labels[self_:read()] end,
+    -- the label of the rung actually in force, which is not the stored one
+    -- when that rung has been gated away (see get)
+    value = function()
+      local i = self_:read()
+      return self_.labels[self_:allows(i) and i or 1]
+    end,
     step = function(game, dir)
       self_:cycle(game, dir)
       return true
@@ -120,7 +191,11 @@ end
 -- The row the mod manager's own settings page builds for this mod.
 function ModSetting:schema(help)
   local choices = {}
-  for i, v in ipairs(self.values) do choices[i] = { self.labels[i], v } end
+  -- gated rungs are left off the manager's page too, so the two rows agree
+  -- about what can be chosen
+  for i, v in ipairs(self.values) do
+    if self:allows(i) then choices[#choices + 1] = { self.labels[i], v } end
+  end
   if #self.values == 2 and self.values[1] == false then
     return { key = self.key, type = "toggle", label = self.label,
              default = self.values[1], help = help }
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index 2114264..c074008 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -60,13 +60,47 @@ if DEBUG == nil or DEBUG == false then DEBUG = nil end
 OverworldBattle.KEY = "battles"
 OverworldBattle.LABEL = "3D-BTL"
 
--- On by default: a mod whose headline is "the world in 3D" should not need
--- the player to go and find the switch before the world shows up in a
--- battle. ON is first, so it is also what an unreadable stored value falls
--- back to.
-OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
-                                         OverworldBattle.LABEL,
-                                         { true, false }, { "ON", "OFF" })
+-- Four rungs, and the ladder's order is the order they were added in rather
+-- than OFF-to-most:
+--
+--   2D-3D      the mode this file was written for: the fight is staged on
+--              the map and the two Pokemon are the GB's OWN PICS, stood up
+--              on their tiles as quads (BattleBillboard).
+--   STADIUM A  the same staged fight with the Pokemon Stadium battle models
+--              in place of those quads -- skinned, animated, and playing the
+--              animation the move being used actually calls for (see
+--              lib/Stadium.lua). The world is still the world: the fight
+--              happens on real ground, in the map's own weather and light.
+--   STADIUM B  the same models on a pair of DISCS against the sky, with no
+--              map at all -- the Game Boy's own framing, staged rather than
+--              found (see lib/StadiumStage.lua). Works everywhere, including
+--              the maps that have nowhere to put a fight.
+--   OFF        the engine's own white battle screen.
+--
+-- 2D-3D stays FIRST because ModSetting's values[1] is both the default and
+-- what an unrecognised stored value falls back to, and the stored value for
+-- this row has been `true` since the row existed. Keeping `true` at the head
+-- means every save written before the STADIUM rungs existed reads back as
+-- the 2D-3D it was written for, and a mod whose headline is "the world in
+-- 3D" still does not need the player to go and find the switch.
+--
+-- The stored value for STADIUM A is still the bare string "stadium" it was
+-- before there was a B, so a save written against the three-rung ladder
+-- keeps the mode it chose.
+--
+-- Both STADIUM rungs are GATED on the models existing: the mod ships no
+-- Pokemon Stadium data, and until the player's own ROM has been found and
+-- built from (StadiumInstall) the row simply has two fewer stops. See
+-- ModSetting.setGate for why they are skipped rather than shown and refused.
+OverworldBattle.setting =
+  ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,
+                 { true, "stadium", "stadiumB", false },
+                 { "2D-3D", "STADIUM A", "STADIUM B", "OFF" })
+  :setGate(function(value)
+    if value ~= "stadium" and value ~= "stadiumB" then return true end
+    local ok, install = pcall(V.require, "StadiumInstall")
+    return ok and install and install.available()
+  end)
 
 -- Whether the VR row is ON -- read lazily, because VR requires modules
 -- that sit above this one. While it is, this mode stops being optional:
@@ -83,6 +117,15 @@ function OverworldBattle.enabled()
   return OverworldBattle.setting:get() and true or false
 end
 
+-- Whether the STADIUM rung is the one selected -- read through Stadium so
+-- there is one answer to that question and it lives with the mode it
+-- describes. Required lazily: Stadium sits above this file and requires it
+-- back (for the row), which a load-time require would deadlock.
+function OverworldBattle.stadium()
+  local ok, stadium = pcall(V.require, "Stadium")
+  return (ok and stadium and stadium.enabled()) and true or false
+end
+
 -- ------- BACK SPRITES: the player's own mon stays on the menu
 --
 -- The staged shot stands BOTH mons on the map, which is the mode's whole
@@ -173,6 +216,10 @@ function OverworldBattle.wantsFront()
   local g = require("src.core.Game")
   local ow = g and g.overworld
   if not (ow and ow.map and ow.player) then return false end
+  -- STADIUM B carries its own stage, so the answer is yes on every map and
+  -- there is nothing to search or to cache
+  local okS, stadium = pcall(V.require, "Stadium")
+  if okS and stadium and stadium.discs() then return true end
   if staged.mapId ~= ow.map.id then
     local ok, arena = pcall(BattleArena.find, ow.map,
                             ow.player.cellX, ow.player.cellY,
@@ -420,6 +467,31 @@ function OverworldBattle.forceOG(g)
   return true
 end
 
+-- Where THIS fight stands, on whichever rung is running: the map's own
+-- ground, or the pair of discs STADIUM B carries with it.
+--
+-- The one place the two rungs actually diverge, and it is worth stating
+-- plainly. On every other rung the answer can be NO -- a corridor, a shop
+-- floor, a map whose authored entry is a refusal -- and the battle then plays
+-- exactly as the vanilla game does. STADIUM B cannot fail: its stage is not
+-- something the map has to have room for, so a fight in the tightest cave in
+-- Kanto is staged as readily as one on Route 1.
+function OverworldBattle.stageFor(state)
+  local ok, stadium = pcall(V.require, "Stadium")
+  if ok and stadium and stadium.discs() and Voxel3D.available() then
+    local okStage, arena = pcall(function()
+      return V.require("StadiumStage").arena(state.map)
+    end)
+    if okStage and arena then return arena end
+    -- the discs could not be built; fall through to the map, which is a
+    -- worse picture but a real one
+  end
+  local okFind, arena = pcall(BattleArena.find, state.map,
+                              state.player.cellX, state.player.cellY,
+                              state.player.surfing)
+  return (okFind and arena) or nil
+end
+
 -- Stage a battle triggered from `state`, if this mode can. Returns true when
 -- a session started -- which is also the only case where anything visible
 -- changes, so a map with no room for an arena plays exactly the vanilla
@@ -430,10 +502,8 @@ function OverworldBattle.begin(state, battle)
   if not (state and state.map and state.player) then return false end
   if not Voxel3D.available() then return false end
 
-  local ok, arena = pcall(BattleArena.find, state.map,
-                          state.player.cellX, state.player.cellY,
-                          state.player.surfing)
-  if not (ok and arena) then return false end
+  local arena = OverworldBattle.stageFor(state)
+  if not arena then return false end
 
   -- the fight is staged from here on, so the layout it is composed for is not
   -- optional any more (see forceOG)
@@ -443,6 +513,9 @@ function OverworldBattle.begin(state, battle)
               armed = false, token = 0 }
   cullCast(state)
   BattleCam.reset()
+  -- and, on the STADIUM rung, the pair of models that will stand on this
+  -- arena's two cells. Declines quietly on any other rung.
+  pcall(function() V.require("Stadium").begin(arena) end)
   return true
 end
 
@@ -473,6 +546,7 @@ function OverworldBattle.finish()
   restoreCast()
   session = nil
   Voxel3D.camera = nil
+  pcall(function() V.require("Stadium").finish() end)
 end
 
 -- ------- per-frame
@@ -524,6 +598,17 @@ function OverworldBattle.update(dt)
   -- slice: nothing visible can hitch on them
   ChunkMesher.pump(true)
 
+  -- The STADIUM models, ahead of the pics, because what they decide is
+  -- WHICH pics are needed: a side a model is standing on gets no billboard
+  -- texture rendered for it at all (see Stadium.covers). Posed and skinned
+  -- here too, once for the frame -- the sun pass, the camera and, in a
+  -- headset, both eyes all draw the same skinned meshes.
+  pcall(function()
+    local host = (session.arena and session.arena.map) or session.state.map
+    V.require("Stadium").update(dt, session.battle,
+                                BattleScene.groundY(host, session.arena))
+  end)
+
   -- The mons' textures are rendered HERE, with no canvas bound, for the same
   -- reason the scene is: the pics layer binds its own targets, and doing that
   -- inside somebody else's frame means putting the frame back afterwards.
@@ -710,6 +795,9 @@ function OverworldBattle.invalidate()
   BattleDOF.invalidate()
   BattleHud.invalidate()
   BattlePics.invalidate()
+  -- the STADIUM models hold meshes and textures of this graphics context
+  -- like everything else here does
+  pcall(function() V.require("Stadium").invalidate() end)
 end
 
 -- ------- the battle screen's background
@@ -908,6 +996,14 @@ local OFF = {
 -- feet ended up, in canvas coordinates.
 function OverworldBattle.sideTexture(battle, side)
   if not (innerPics and battle) then return nil end
+  -- On the STADIUM rung a side standing a MODEL needs no pic: rendering one
+  -- anyway would hang a second, flat copy of the same Pokemon on the same
+  -- cell. Asked per side, so a species with no pack -- or a substitute
+  -- doll, or the trainer before the send-out -- still comes through here.
+  local okS, covered = pcall(function()
+    return V.require("Stadium").covers(battle, side)
+  end)
+  if okS and covered then return nil end
   if not sideVisible(battle, side) then return nil end
   local canvas = texCanvasFor(side)
   if not canvas then return nil end
@@ -985,7 +1081,16 @@ function OverworldBattle.textures(battle)
   end
   out.enemy = okE and enemy or nil
   out.player = okP and player or nil
-  if not (out.enemy or out.player) then return nil end
+  -- On the STADIUM rung both sides can legitimately have no pic -- the pair
+  -- of them are models -- and this table must still come back, because it
+  -- carries the HIT FLASH, and because the VR eye pass uses its presence to
+  -- decide there is a staged fight to draw at all.
+  local okStanding, standing = pcall(function()
+    return V.require("Stadium").standing()
+  end)
+  if not (out.enemy or out.player or (okStanding and standing)) then
+    return nil
+  end
   out.flash = OverworldBattle.flashing(battle)
   return out
 end
@@ -1008,6 +1113,12 @@ function OverworldBattle.install()
     OverworldState.dramaticShapeBattleHook = true
   end
 
+  -- the STADIUM rung's own four wraps, which drive the models' animations
+  -- off the fight (see Stadium.install). Idempotent in the same way, and
+  -- installed whichever rung the row is on: the wraps do nothing at all
+  -- while no stadium session is live.
+  pcall(function() V.require("Stadium").install() end)
+
   local BattleState = require("src.battle.BattleState")
   if BattleState.dramaticShapeBattleHook then return end
 
diff --git a/lib/Stadium.lua b/lib/Stadium.lua
new file mode 100644
index 0000000..1103b20
--- /dev/null
+++ b/lib/Stadium.lua
@@ -0,0 +1,516 @@
+-- STADIUM battles: the two Pokemon as real 3D models.
+--
+-- The 3D-BTL row's third rung. OFF is the engine's own white battle field;
+-- 2D-3D stands the GB's own pics up on the map as quads (BattleBillboard);
+-- STADIUM replaces those quads with the Pokemon Stadium battle models --
+-- skinned, animated, and playing the animation the move being used
+-- actually calls for.
+--
+-- The models come out of the Stadium ROM through model_extract, and are
+-- packed into assets/stadium/NNN.dsm by tools/stadium_pack.py. Nothing here
+-- knows about the ROM; the pack is the interface.
+--
+-- ------- what this file is, and is not
+--
+-- It is the MODE: which species is out on each side, which animation the
+-- fight is asking each of them for, whether the model or the flat pic is
+-- standing in this frame, and the two draw calls. The arithmetic is
+-- StadiumRig's, the file format is StadiumPack's, and one side's own state
+-- is StadiumMon's.
+--
+-- It is not a rewrite of the staged battle. The arena is picked the same
+-- way, the camera is solved the same way, the HUDs and the text box and the
+-- move animations and the depth of field are all exactly what 2D-3D draws
+-- -- because all of those are hung off the arena's CELLS, not off the
+-- pics. Swapping what stands on a cell changes nothing about where the cell
+-- projects to. That is why this is an option on the mode rather than a
+-- second mode.
+--
+-- ------- declining, per Pokemon
+--
+-- Every gate here is per SIDE and per FRAME, not per battle:
+--
+--   no pack for that species, or its meshes would not build -> that side
+--   falls back to its flat pic, and the other side keeps its model
+--
+--   the side is showing a TRAINER (the foe's class before the send-out,
+--   the player's own back before "Go!") -> that is not a Pokemon and there
+--   is no model for it; the pic stands, exactly as in 2D-3D
+--
+--   a SUBSTITUTE is up -> the engine replaces the pic with the mini doll,
+--   which is the thing the player is being told is there. A model of the
+--   Pokemon behind the doll would be a lie about the battle state.
+--
+-- So `covers` is asked per side per frame, and OverworldBattle renders a
+-- billboard texture for exactly the sides it answers false for.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Voxel3D = V.require("Voxel3D")
+local StadiumPack = V.require("StadiumPack")
+local StadiumMon = V.require("StadiumMon")
+
+local Stadium = {}
+
+-- The stored values of the two 3D-BTL rungs that select this mode. Strings
+-- rather than further booleans so an older save's `true` still means the
+-- 2D-3D it was written for (see OverworldBattle.setting).
+--
+--   A  the models on the MAP -- real ground, the map's own light and sky
+--   B  the models on two DISCS against the sky, with no map at all
+--
+-- Everything below is shared: which species is out, which animation the
+-- fight is asking for, the skinning, the draw. The difference is entirely
+-- in what the camera is pointed at, which is BattleScene's business and
+-- StadiumStage's.
+Stadium.VALUE = "stadium"
+Stadium.VALUE_B = "stadiumB"
+
+-- ------- the live pair
+
+local session = nil     -- nil when no staged fight is running
+
+local function game()
+  return require("src.core.Game")
+end
+
+-- Whether the row is on this rung. Deliberately NOT gated on whether the
+-- packs are installed: a mod folder without assets/stadium still cycles the
+-- row, and each Pokemon declines on its own when its pack does not load --
+-- which is one message on the console rather than a row that silently
+-- refuses to move.
+function Stadium.selected()
+  return Stadium.mode() ~= nil
+end
+
+-- "A", "B", or nil when the row is on neither stadium rung.
+function Stadium.mode()
+  local OverworldBattle = V.require("OverworldBattle")
+  local value = OverworldBattle.setting:get()
+  if value == Stadium.VALUE then return "A" end
+  if value == Stadium.VALUE_B then return "B" end
+  return nil
+end
+
+-- Whether the fight is staged on the DISCS rather than on the map. Asked by
+-- BattleScene (what to draw the pair against), BattleArena (whether the map
+-- has to have room) and BattleCam (which framing to solve).
+function Stadium.discs()
+  return Stadium.mode() == "B"
+end
+
+function Stadium.enabled()
+  if not Stadium.selected() then return false end
+  return Voxel3D.available()
+end
+
+-- A staged fight has begun on `arena`. Called from OverworldBattle.begin,
+-- which is the one place that knows a fight is being staged at all.
+function Stadium.begin(arena)
+  Stadium.finish()
+  if not Stadium.enabled() then return false end
+  session = {
+    arena = arena,
+    groundY = 0,
+    player = StadiumMon.new("player"),
+    enemy = StadiumMon.new("enemy"),
+    -- what each side has been TRANSFORMED into, if anything (see install)
+    transform = {},
+    -- sides that are going to collapse, but whose HP bar has not finished
+    -- emptying yet (see faintReady)
+    faintPending = {},
+  }
+  return true
+end
+
+function Stadium.finish()
+  if not session then return end
+  session.player:release()
+  session.enemy:release()
+  session = nil
+end
+
+function Stadium.active()
+  return session ~= nil
+end
+
+-- ------- which species each side is showing
+
+-- The National Dex number for a battler, which is the number the Stadium
+-- packs are keyed by. The engine's species are string keys ("PIKACHU") and
+-- carry their dex number on the definition, so this is one lookup rather
+-- than a table of its own.
+local function dexOf(species)
+  if not species then return nil end
+  local data = game() and game().data
+  local def = data and data.pokemon and data.pokemon[species]
+  return def and def.dex or nil
+end
+
+-- Whether this side is showing a TRAINER rather than a Pokemon.
+local function showingTrainer(battle, side)
+  if side == "enemy" then
+    return (battle.showEnemyTrainer and battle.trainerPic) and true or false
+  end
+  return (battle.showPlayerBack and battle.playerBackPic) and true or false
+end
+
+-- Whether this side has anything on the field at all this frame.
+--
+-- Mirrors BattleState's own guards, the same way OverworldBattle.sideVisible
+-- mirrors them for the flat cards: there is no seam that reports "the foe is
+-- off screen right now", and a model left standing through a send-out or a
+-- damage blink would be the one thing in the frame that ignored the battle.
+local function onField(battle, side)
+  local battler = side == "player" and battle.player or battle.enemy
+  if not (battler and battler.sprite) then return false end
+  if side == "enemy" then
+    if battle.enemyHidden or battle.enemySendingOut then return false end
+  else
+    if battle.safari or battle.demo or battle.sendingOut then return false end
+  end
+  local ok, hidden = pcall(battle.fxHidden, battle, battler)
+  if ok and hidden then return false end
+  -- a fainted Pokemon is gone once its slide has finished, exactly as its
+  -- pic is
+  if battler.fainted then
+    local okF, sliding = pcall(battle.fxFaintActive, battle, battler)
+    if not (okF and sliding) then return false end
+  end
+  return true
+end
+
+-- Whether the 3D model is standing in for this side's pic this frame. The
+-- one question OverworldBattle asks, and the answer that decides whether a
+-- billboard texture gets rendered for that side at all.
+function Stadium.covers(battle, side)
+  if not (session and battle) then return false end
+  local mon = session[side]
+  if not (mon and mon.rig) then return false end
+  if showingTrainer(battle, side) then return false end
+  local battler = side == "player" and battle.player or battle.enemy
+  -- the substitute doll is what the player is being shown is out there
+  if battler and battler.substituteHP then return false end
+  return true
+end
+
+-- ------- the collapse waits for the bar
+--
+-- `onFaint` runs the instant HP reaches zero, which is NOT when a Pokemon
+-- falls over. The engine queues the collapse -- the slide, the cry, the
+-- "fainted!" line -- to run after the move animation and the HP-bar drain
+-- (BattleState.onFaint's own comment), and the drain takes real frames: a
+-- 150 HP mon's bar walks down over some four seconds.
+--
+-- So asking for the faint animation at `onFaint` played it against a bar
+-- that was still emptying: the Pokemon lay down, and then its health went on
+-- draining above the corpse. What the player reads as the moment of death is
+-- the bar hitting zero, and that is what this waits for.
+--
+-- `shownHP` is the engine's own bar position (BattleState.stepHPDrain walks
+-- it toward mon.hp a point at a time), so this is not a guess at the timing
+-- -- it is the same number the bar is drawn from.
+local function faintReady(battler)
+  if not battler then return false end
+  -- nothing is animating the bar for this battler: there is nothing to wait
+  -- for, and waiting forever would mean never collapsing at all
+  if battler.shownHP == nil then return true end
+  return battler.shownHP <= 0
+end
+
+-- Whether a pending collapse is still owed. A switch, a revive or a battler
+-- that was replaced under us drops it rather than firing late at whoever is
+-- standing there now.
+local function faintStillDue(battler)
+  return (battler and battler.faintQueued
+          and battler.mon and (battler.mon.hp or 0) <= 0) and true or false
+end
+
+-- named for the suite: the timing rule is the whole of this change, and it
+-- is testable without a graphics context where the mode itself is not
+Stadium._faintReady = faintReady
+Stadium._faintStillDue = faintStillDue
+
+-- ------- per frame
+--
+-- Runs from OverworldBattle.update, before the pics are rendered and before
+-- the scene is drawn: what this decides is exactly which sides need a pic.
+function Stadium.update(dt, battle, groundY)
+  if not session then return end
+  session.groundY = groundY or session.groundY or 0
+  if not battle then return end
+
+  local arena = session.arena
+  for _, side in ipairs({ "enemy", "player" }) do
+    local mon = session[side]
+    local battler = side == "player" and battle.player or battle.enemy
+    local dex = nil
+    if battler and not showingTrainer(battle, side) then
+      dex = session.transform[side] or dexOf(battler.mon and battler.mon.species)
+    end
+    -- the collapse this side is owed, once its bar has finished emptying
+    if session.faintPending and session.faintPending[side] then
+      if not faintStillDue(battler) then
+        session.faintPending[side] = nil
+      elseif faintReady(battler) then
+        session.faintPending[side] = nil
+        if mon and mon.rig then mon:request("faint") end
+      end
+    end
+
+    mon:setSpecies(dex)
+    mon.visible = (mon.rig ~= nil) and onField(battle, side)
+                  and not (battler and battler.substituteHP)
+    -- cleared up front, so a side that has just lost its rig cannot leave
+    -- last frame's matrix behind it
+    mon.model_matrix = nil
+
+    if mon.rig then
+      -- the send-out grow, borrowed whole from the engine: the pic scales
+      -- up out of the ball in three steps and so does the model
+      local okG, grow = pcall(battle.growInScale, battle, battler)
+      mon.scale = (okG and grow) or 1
+      mon:update(dt or 0)
+      if mon.visible and arena then
+        local cell = arena[side]
+        local other = arena[side == "player" and "enemy" or "player"]
+        if cell and other then
+          mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2],
+                                        other[1] - cell[1],
+                                        other[2] - cell[2])
+          mon:build()
+        else
+          mon.model_matrix = nil
+        end
+      else
+        mon.model_matrix = nil
+      end
+    end
+  end
+  Stadium.debug(dt)
+end
+
+-- ------- the draws
+--
+-- Both take the pass as they find it: this is called from inside
+-- BattleScene's own beginScene/endScene window (and, in a headset, from
+-- VoxelScene's), so the camera, the shadow map, the hour's tint and the hit
+-- flash are all already set. StadiumRig turns the wireframe and the glass
+-- mask off around its own draws and puts them back.
+
+function Stadium.draw(pull)
+  if not session then return end
+  for _, side in ipairs({ "enemy", "player" }) do
+    local mon = session[side]
+    if mon.rig and mon.visible and mon.model_matrix then
+      mon.rig:draw(mon.model_matrix, pull)
+    end
+  end
+end
+
+-- The same models as the SUN sees them, so a Pokemon throws the shadow of
+-- the pose it is actually in -- an outstretched wing puts an outstretched
+-- wing on the ground.
+function Stadium.cast(shadowMap)
+  if not session then return end
+  for _, side in ipairs({ "enemy", "player" }) do
+    local mon = session[side]
+    if mon.rig and mon.visible and mon.model_matrix then
+      mon.rig:caster(shadowMap, mon.model_matrix)
+    end
+  end
+end
+
+-- Which state a side's model is playing, or nil. Named for the shot drivers:
+-- checking that an animation starts on the right FRAME is an ordering
+-- question, and a screenshot cannot answer one.
+function Stadium.animOf(side)
+  if not session then return nil end
+  local mon = session[side]
+  return mon and mon.state or nil
+end
+
+-- How wide the Pokemon on `side` stands, in world pixels, or nil when there
+-- is not one. What STADIUM B sizes that side's platform to (StadiumStage).
+function Stadium.footprint(side)
+  if not session then return nil end
+  local mon = session[side]
+  if not (mon and mon.model) then return nil end
+  local r = mon:worldRadius()
+  return (r > 0) and r or nil
+end
+
+-- Whether anything at all is standing this frame -- what the shadow
+-- signature keys on alongside the pics' own token.
+function Stadium.standing()
+  if not session then return false end
+  return (session.player.visible or session.enemy.visible) and true or false
+end
+
+-- ------- what the fight asks for
+--
+-- The animation state machine is driven from four points in the engine's
+-- own battle, and each is a wrap rather than a rewrite: the inner function
+-- runs exactly as it always did and this reads what went past.
+
+local function sideOf(battle, battler)
+  if not (session and battler) then return nil end
+  if battler == battle.player then return "player" end
+  if battler == battle.enemy then return "enemy" end
+  return nil
+end
+
+local function ask(battle, battler, state, animIndex, auxIndex)
+  local side = sideOf(battle, battler)
+  if not side then return end
+  local mon = session[side]
+  if mon and mon.rig then mon:request(state, animIndex, auxIndex) end
+end
+
+function Stadium.install()
+  local BattleState = require("src.battle.BattleState")
+  if BattleState.dramaticShapeStadiumHook then return end
+  BattleState.dramaticShapeStadiumHook = true
+
+  -- THE ATTACK. performMove is the one place a move is actually used, and
+  -- the move's own `index` is the Gen 1 move id the Stadium tables are
+  -- keyed by -- so the species' own animation for that move comes straight
+  -- out of the pack, with no name mapping and no per-move code.
+  local innerMove = BattleState.performMove
+  function BattleState:performMove(user, target, moveInst, isCalled)
+    if session then
+      local side = sideOf(self, user)
+      local mon = side and session[side]
+      if mon and mon.rig then
+        local okDef, def = pcall(self.moveDef, self, moveInst)
+        local index = okDef and def and def.index or nil
+        if not (index and mon:attack(index)) then
+          -- a move the table has nothing for still swings: the generic
+          -- attack is what the species' own reaction slot resolves to
+          mon:request("attack")
+        end
+      end
+    end
+    return innerMove(self, user, target, moveInst, isCalled)
+  end
+
+  -- THE HIT. applyDamage is where HP actually comes off, which is the
+  -- moment the reaction belongs to -- ahead of the bar drain and the
+  -- message, both of which take frames.
+  local innerDamage = BattleState.applyDamage
+  function BattleState:applyDamage(target, dmg)
+    local dealt = innerDamage(self, target, dmg)
+    -- a substitute soaking the hit means the Pokemon behind it did not
+    -- flinch, and its model is not the thing on screen anyway
+    if session and (dealt or 0) > 0 and not (target and target.substituteHP) then
+      ask(self, target, "hit")
+    end
+    return dealt
+  end
+
+  -- THE FAINT. Held on its last frame rather than looped (see StadiumMon's
+  -- STATES), because a Pokemon that collapses and then stands back up
+  -- while the message is still on screen is worse than no animation.
+  --
+  -- RECORDED HERE, PLAYED LATER. This runs the moment HP reaches zero, which
+  -- is several seconds before the Pokemon is supposed to fall over -- the
+  -- engine queues the collapse behind the move animation and the HP-bar
+  -- drain. Marking the side and letting Stadium.update fire it when the bar
+  -- empties is what keeps the two together (see faintReady).
+  local innerFaint = BattleState.onFaint
+  function BattleState:onFaint(battler)
+    if session and not (battler and battler.faintQueued) then
+      local side = sideOf(self, battler)
+      if side and session.faintPending then
+        session.faintPending[side] = true
+      end
+    end
+    return innerFaint(self, battler)
+  end
+
+  -- THE ENTRANCE. startGrowIn is the send-out: the ball opens, the pic
+  -- scales up over twelve frames, and the model plays the animation the
+  -- battle system's own entrance slot names.
+  local innerGrow = BattleState.startGrowIn
+  function BattleState:startGrowIn(battler)
+    if session then ask(self, battler, "entrance") end
+    return innerGrow(self, battler)
+  end
+
+  -- TRANSFORM. The engine records a transform by swapping the battler's
+  -- sprite and nothing else, so this is the only seam that reports one --
+  -- and it reports the side, which is all that is needed to point that
+  -- side's model at the copied species. Cleared when a side's own species
+  -- changes under it (a switch, or the next battle).
+  local innerSpecies = BattleState.speciesSprite
+  function BattleState:speciesSprite(species, isPlayerSide)
+    if session then
+      session.transform[isPlayerSide and "player" or "enemy"] = dexOf(species)
+    end
+    return innerSpecies(self, species, isPlayerSide)
+  end
+
+  -- and a switch or a send-out ends any transform on that side
+  local innerSwitch = BattleState.resolveSwitch
+  function BattleState:resolveSwitch(newMon)
+    if session then session.transform.player = nil end
+    return innerSwitch(self, newMon)
+  end
+end
+
+-- ------- when a draw goes wrong
+--
+-- The draw and the shadow cast are both called through a pcall, because a
+-- throw inside the scene pass would hand the whole voxel mode to Pipelines'
+-- guard and retire it for the session. Swallowed silently, though, a broken
+-- model is indistinguishable from an invisible one -- so the first failure
+-- of a battle says so, once, and the rest of the fight carries on without
+-- it.
+local reported = false
+
+function Stadium.report(err)
+  if reported then return end
+  reported = true
+  V.mod.log:warn("stadium: a model failed to draw: %s -- this battle runs "
+                 .. "without it", tostring(err))
+end
+
+-- DS_STADIUM_DEBUG=1 prints what each side resolved to once a second, which
+-- is how "nothing is on screen" gets told apart from "nothing was asked
+-- for". Read through pcall: the loader's sandbox does not hand a mod `os`,
+-- and a diagnostic must never be why the mod fails to load.
+local DEBUG = select(2, pcall(function() return os.getenv("DS_STADIUM_DEBUG") end))
+if DEBUG == nil or DEBUG == false then DEBUG = nil end
+
+local debugAt = 0
+
+function Stadium.debug(dt)
+  if not (DEBUG and session) then return end
+  debugAt = debugAt + (dt or 0)
+  if debugAt < 1 then return end
+  debugAt = 0
+  for _, side in ipairs({ "enemy", "player" }) do
+    local mon = session[side]
+    local m = mon.model_matrix
+    V.mod.log:info("stadium %s: dex=%s rig=%s visible=%s anim=%s t=%.2f "
+                   .. "height=%.1f at=%s",
+                   side, tostring(mon.species), tostring(mon.rig ~= nil),
+                   tostring(mon.visible), tostring(mon.anim), mon.time or 0,
+                   mon.model and mon:worldHeight() or 0,
+                   m and ("%.0f,%.0f,%.0f"):format(m[4], m[8], m[12]) or "-")
+  end
+end
+
+function Stadium.invalidate()
+  if session then
+    session.player:release()
+    session.enemy:release()
+  end
+  StadiumPack.invalidate()
+  -- the discs are a mesh and a texture like anything else, and a graphics
+  -- context that went away took them with it
+  pcall(function() V.require("StadiumStage").invalidate() end)
+end
+
+return Stadium
diff --git a/lib/StadiumBuild.lua b/lib/StadiumBuild.lua
new file mode 100644
index 0000000..c84b479
--- /dev/null
+++ b/lib/StadiumBuild.lua
@@ -0,0 +1,709 @@
+-- STADIUM battles: turning the ROM into assets/stadium/NNN.dsm.
+--
+-- The Lua half of tools/stadium_pack.py: measure the bind pose, decide
+-- whether a species' standby loop can be trusted, and write the packed file.
+-- Together with StadiumRom, StadiumFragment and StadiumFx this is everything
+-- between `baserom.z64` and a Pokemon standing on a battle tile.
+--
+-- The Python remains the ORACLE. tests/stadium_extract_test.lua runs this
+-- over the same ROM and requires all 151 files to come out byte for byte
+-- identical to what the packer writes. That is a strong test in a way a unit
+-- test of any one function here would not be: every rounding mode, every
+-- iteration order, every off-by-one in an index shows up as a differing byte,
+-- and there are thirty-four megabytes of them.
+--
+-- ------- stepped, not blocking
+--
+-- `StadiumBuild.job()` returns a coroutine-backed job that does one species
+-- per `step()`, so the caller can draw a progress bar between them
+-- (StadiumInstall). A species is a few tens of milliseconds; the whole set is
+-- around half a minute, which is far too long to spend inside one frame and
+-- perfectly fine spread across a loading screen.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local StadiumRom = V.require("StadiumRom")
+local StadiumFragment = V.require("StadiumFragment")
+local StadiumFx = V.require("StadiumFx")
+
+local StadiumBuild = {}
+
+local floor = math.floor
+local sin, cos = math.sin, math.cos
+local pi = math.pi
+local char = string.char
+local concat = table.concat
+local frexp = math.frexp
+local roundHalfEven = StadiumFragment.roundHalfEven
+
+-- The battle system's fixed context slots, in slot order from 165. The mod
+-- indexes this list by POSITION, so the ORDER is the format's contract and
+-- has to stay identical to StadiumPack.CONTEXT and to the packer's CONTEXTS.
+StadiumBuild.CONTEXTS = {
+  "idle", "hit", "faint", "entrance", "reaction_169", "reaction_170",
+  "reaction_171", "reaction_172", "reaction_173", "reaction_174",
+  "struggle", "idle_alt", "faint_alt", "flinch", "reaction_179",
+  "reaction_180", "reaction_181", "reaction_182", "entrance_alt",
+  "idle_return",
+}
+
+-- Which context name wins when several claim the same animation.
+local NAME_PREF = { "idle", "hit", "faint", "entrance", "struggle", "flinch" }
+
+local N_MOVES = StadiumRom.N_MOVES
+local CTX_BASE = 165
+local NONE16 = 0xFFFF
+
+-- ------- the bind pose
+
+-- The game's rotation as a 3x3, rows first (src/F420.c func_8000F730):
+-- Rx*Ry*Rz in row-vector form.
+local function quatBasis(r)
+  local sx, cx = sin(r[1] / 32768 * pi), cos(r[1] / 32768 * pi)
+  local sy, cy = sin(r[2] / 32768 * pi), cos(r[2] / 32768 * pi)
+  local sz, cz = sin(r[3] / 32768 * pi), cos(r[3] / 32768 * pi)
+  return { cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz },
+         { cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz },
+         { -sy, sx * cy, cx * cy }
+end
+
+-- 3x4 (three rotation rows plus a translation column) times the same.
+local function matMul(a, b)
+  local out = {}
+  for r = 1, 3 do
+    local ar = a[r]
+    out[r] = {
+      ar[1] * b[1][1] + ar[2] * b[2][1] + ar[3] * b[3][1],
+      ar[1] * b[1][2] + ar[2] * b[2][2] + ar[3] * b[3][2],
+      ar[1] * b[1][3] + ar[2] * b[2][3] + ar[3] * b[3][3],
+      ar[1] * b[1][4] + ar[2] * b[2][4] + ar[3] * b[3][4] + ar[4],
+    }
+  end
+  return out
+end
+
+-- One component of one bone's t/r/s at a frame. The extractor's own shape: a
+-- bare number when the component holds still for the whole animation, one
+-- number a frame when it does not.
+local function component(comps, i, frame, fallback)
+  if comps == nil then return fallback end
+  local c = comps[i]
+  if type(c) == "table" then
+    local n = #c
+    if n == 0 then return fallback end
+    return c[frame % n + 1]
+  end
+  return c
+end
+
+-- The bone TRS an animation holds at `frame`, rest where it is silent.
+local function animSample(bones, anim, frame)
+  local tracks = anim.tracks
+  return function(i)
+    local b = bones[i]
+    local tr = tracks[i]
+    if not tr then return b.t, b.r, b.s end
+    return { component(tr.t, 1, frame, b.t[1]),
+             component(tr.t, 2, frame, b.t[2]),
+             component(tr.t, 3, frame, b.t[3]) },
+           { component(tr.r, 1, frame, b.r[1]),
+             component(tr.r, 2, frame, b.r[2]),
+             component(tr.r, 3, frame, b.r[3]) },
+           { component(tr.s, 1, frame, b.s[1]),
+             component(tr.s, 2, frame, b.s[2]),
+             component(tr.s, 3, frame, b.s[3]) }
+  end
+end
+
+local function restSample(bones)
+  return function(i)
+    local b = bones[i]
+    return b.t, b.r, b.s
+  end
+end
+
+-- Every bone's draw matrix at one instant, as 3x4 rows.
+--
+-- The game keeps bone scale OUT of the matrix chain: it accumulates in its own
+-- stack, a bone's local translation is pre-multiplied by the PARENT's
+-- accumulated scale, and the bone's own accumulated scale is applied to the
+-- finished matrix at draw time.
+--
+-- Two chains, and the distinction is the whole point: `pivot` is the
+-- rotation/translation a CHILD inherits, and the draw matrix is that with the
+-- bone's own accumulated scale applied on the right. Folding the scale into
+-- the chain instead applies every ancestor's scale twice -- which is exactly
+-- the multiplicative propagation glTF has and the game does not.
+local function bindMatrices(bones, sample)
+  sample = sample or restSample(bones)
+  local pivot, draw, acc = {}, {}, {}
+  local IDENT = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 } }
+  for i = 1, #bones do
+    local bt, br, bs = sample(i)
+    local p = bones[i].parent
+    local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
+    local pm = (p >= 0) and pivot[p + 1] or IDENT
+    local r1, r2, r3 = quatBasis(br)
+    local m = matMul(pm, {
+      { r1[1], r1[2], r1[3], bt[1] * pa[1] },
+      { r2[1], r2[2], r2[3], bt[2] * pa[2] },
+      { r3[1], r3[2], r3[3], bt[3] * pa[3] },
+    })
+    local a = { pa[1] * bs[1], pa[2] * bs[2], pa[3] * bs[3] }
+    acc[i] = a
+    pivot[i] = m
+    -- scale on the right: the bone's own space, so it cannot reach children
+    draw[i] = {
+      { m[1][1] * a[1], m[1][2] * a[2], m[1][3] * a[3], m[1][4] },
+      { m[2][1] * a[1], m[2][2] * a[2], m[2][3] * a[3], m[2][4] },
+      { m[3][1] * a[1], m[3][2] * a[2], m[3][3] * a[3], m[3][4] },
+    }
+  end
+  return draw
+end
+
+StadiumBuild.bindMatrices = bindMatrices
+StadiumBuild.animSample = animSample
+
+-- The axis-aligned box the whole model occupies under `mats`, in game units
+-- after the model_root scale.
+local function poseBox(data, mats)
+  local root = data.rootScale[1]
+  local lo1, lo2, lo3 = 1e30, 1e30, 1e30
+  local hi1, hi2, hi3 = -1e30, -1e30, -1e30
+  for _, prim in ipairs(data.prims) do
+    local pos, skin = prim.pos, prim.skin
+    for i = 1, prim.nverts do
+      local m = mats[skin[i] + 1]
+      if m then
+        local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
+        local a = (m[1][1] * x + m[1][2] * y + m[1][3] * z + m[1][4]) * root
+        local b = (m[2][1] * x + m[2][2] * y + m[2][3] * z + m[2][4]) * root
+        local c = (m[3][1] * x + m[3][2] * y + m[3][3] * z + m[3][4]) * root
+        if a < lo1 then lo1 = a end
+        if b < lo2 then lo2 = b end
+        if c < lo3 then lo3 = c end
+        if a > hi1 then hi1 = a end
+        if b > hi2 then hi2 = b end
+        if c > hi3 then hi3 = c end
+      end
+    end
+  end
+  return lo1, lo2, lo3, hi1, hi2, hi3
+end
+
+-- (height, floor, radius): how tall the mon is, where its lowest point sits
+-- relative to the model's own origin, and how wide it is -- all in game units
+-- after the model_root scale.
+--
+-- Measured on the BIND POSE, which is the one pose in the set that can be
+-- trusted for this. It reproduces the verified glTF export exactly on all 151
+-- species, and it is immune to the animation quirks a handful of them carry
+-- (see idleIsBroken) -- quirks that would otherwise decide how big every OTHER
+-- frame of those species is drawn.
+--
+-- The floor is the interesting number, and it reads cleanly: 119 of the 151
+-- sit within 5% of zero, which says the model origin IS where the game stands
+-- a Pokemon on its field. Every species that does not is one that hovers.
+local function stance(data)
+  local lo1, lo2, lo3, hi1, hi2, hi3 = poseBox(data, bindMatrices(data.bones))
+  if lo1 > hi1 then return 0.0, 0.0, 0.0 end
+  local w, d = hi1 - lo1, hi3 - lo3
+  return hi2 - lo2, lo2, (w > d and w or d) / 2
+end
+
+StadiumBuild.stance = stance
+
+-- Whether this species' standby loop is corrupt in the source data.
+--
+-- A handful come out of the extraction with animations that throw bones
+-- hundreds of units off the body -- Exeggutor, Tangela and Magmar, whose
+-- channel streams the game's own index arithmetic evidently reads differently
+-- from the way this does. Played, they look like a Pokemon coming apart; the
+-- mod would rather stand them still.
+--
+-- The test is deliberately narrow, because "differs from the bind pose" is NOT
+-- brokenness. It is asked only of the STANDBY loop -- the one animation that
+-- is supposed to stay where it is, since a faint is meant to end far from the
+-- standing pose and an attack is meant to lunge -- and it wants both a large
+-- size blow-up and real drift, or an enormous amount of one. Dewgong is what
+-- calibrates it: its idle is 2.4x its own bind pose because the BIND is the
+-- collapsed one, and it drifts barely at all, so it must not be caught.
+local function idleIsBroken(data, idle)
+  if idle == nil then return false end
+  local bones = data.bones
+  local _, lo2, _, _, hi2 = poseBox(data, bindMatrices(bones))
+  local span = hi2 - lo2
+  if span <= 0 then return false end
+  local worstH, worstDrift = 1.0, 0.0
+  local frame = 0
+  while frame < idle.frames do
+    local _, flo2, _, _, fhi2 = poseBox(data,
+      bindMatrices(bones, animSample(bones, idle, frame)))
+    local h = (fhi2 - flo2) / span
+    if h > worstH then worstH = h end
+    local d1 = (flo2 - lo2) / span
+    local d2 = (fhi2 - hi2) / span
+    if d1 < 0 then d1 = -d1 end
+    if d2 < 0 then d2 = -d2 end
+    if d1 > worstDrift then worstDrift = d1 end
+    if d2 > worstDrift then worstDrift = d2 end
+    frame = frame + 3
+  end
+  return (worstH > 2.5 and worstDrift > 1.5)
+         or worstDrift > 2.0 or worstH > 3.4
+end
+
+-- ------- writing
+
+local function clamp(v, lo, hi)
+  if v < lo then return lo end
+  if v > hi then return hi end
+  return v
+end
+
+-- Toward zero, which is what Python's int() does to a float and NOT what
+-- floor() does to a negative one.
+--
+-- It matters in exactly one place, and it is easy to miss: almost everything
+-- reaching the integer writers below has already been rounded, so truncation
+-- is a no-op on it. The exception is the generated effects' crossed quads
+-- (StadiumFx), whose vertices are raw floats and straddle the origin -- so
+-- the ones at negative x, and only those, come out a unit adrift if this
+-- floors.
+local function trunc(v)
+  if v >= 0 then return floor(v) end
+  return -floor(-v)
+end
+
+-- 16.16, which holds every bone scale in the set (-31 .. 100) with more
+-- precision than anything can see.
+local function fixed(v)
+  return clamp(roundHalfEven(v * 65536), -2147483648, 2147483647)
+end
+
+-- IEEE 754 single, little-endian, rounded to nearest with ties to even -- the
+-- same rounding Python's struct.pack('<f') does, so the four floats in the
+-- header come out bit for bit the same as the packer's.
+local function f32(x)
+  local sign = 0
+  if x < 0 or (x == 0 and 1 / x < 0) then
+    sign = 128
+    x = -x
+  end
+  if x ~= x then return char(0, 0, 192, 127 + sign) end        -- NaN
+  if x == math.huge then return char(0, 0, 128, 127 + sign) end
+  if x == 0 then return char(0, 0, 0, sign) end
+  local m, e = frexp(x)                       -- x = m * 2^e, 0.5 <= m < 1
+  local E = e - 1 + 127
+  local mant
+  if E >= 255 then
+    return char(0, 0, 128, 127 + sign)                          -- overflow
+  elseif E <= 0 then
+    -- subnormal: no exponent left, so the mantissa carries the whole value
+    mant = roundHalfEven(x / 2 ^ -149)
+    if mant >= 8388608 then
+      mant, E = mant - 8388608, 1
+    else
+      E = 0
+    end
+  else
+    mant = roundHalfEven((m * 2 - 1) * 8388608)
+    if mant == 8388608 then                   -- rounded up into the next
+      mant, E = 0, E + 1
+      if E >= 255 then return char(0, 0, 128, 127 + sign) end
+    end
+  end
+  local b4 = sign + floor(E / 2)
+  local b3 = (E % 2) * 128 + floor(mant / 65536)
+  local b2 = floor(mant / 256) % 256
+  local b1 = mant % 256
+  return char(b1, b2, b3, b4)
+end
+
+StadiumBuild.f32 = f32
+
+local Writer = {}
+Writer.__index = Writer
+
+local function newWriter()
+  return setmetatable({ parts = {}, n = 0 }, Writer)
+end
+
+function Writer:raw(s)
+  self.n = self.n + 1
+  self.parts[self.n] = s
+end
+
+function Writer:u8(v)
+  self:raw(char(v % 256))
+end
+
+function Writer:i8(v)
+  v = clamp(trunc(v), -128, 127)
+  self:raw(char(v % 256))
+end
+
+function Writer:u16(v)
+  v = v % 65536
+  self:raw(char(v % 256, floor(v / 256)))
+end
+
+function Writer:i16(v)
+  v = clamp(trunc(v), -32768, 32767) % 65536
+  self:raw(char(v % 256, floor(v / 256)))
+end
+
+function Writer:u32(v)
+  v = v % 4294967296
+  self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256,
+                floor(v / 16777216) % 256))
+end
+
+function Writer:i32(v)
+  v = clamp(trunc(v), -2147483648, 2147483647) % 4294967296
+  self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256,
+                floor(v / 16777216) % 256))
+end
+
+function Writer:f32(v)
+  self:raw(f32(v))
+end
+
+function Writer:bytes()
+  return concat(self.parts)
+end
+
+-- One component of one bone's t/r/s in one animation. `values` is the
+-- extractor's own shape: a bare number when the component holds still for the
+-- whole animation, or one number a frame when it does not. That fold is where
+-- most of the size saving is -- a bone that only rotates costs two bytes for
+-- each of its six other components.
+local function writeTrackComponent(w, values, kind)
+  local isArray = type(values) == "table"
+  w:u8(isArray and 1 or 0)
+  if kind == "s" then
+    if isArray then
+      for i = 1, #values do w:i32(fixed(values[i])) end
+    else
+      w:i32(fixed(values))
+    end
+  else
+    if isArray then
+      for i = 1, #values do w:i16(roundHalfEven(values[i])) end
+    else
+      w:i16(roundHalfEven(values))
+    end
+  end
+end
+
+-- Which animation each fixed battle context slot resolves to: entries 165
+-- upward of the species' own battle table, in slot order. An entry naming an
+-- animation the species does not have is written as "none" rather than
+-- clamped -- the mod would rather fall back than play the wrong clip.
+function StadiumBuild.contextTable(rows, nAnims)
+  local ctx = {}
+  for i = 1, #StadiumBuild.CONTEXTS do
+    local row = rows[CTX_BASE + i - 1]
+    local ai = row and row[1] or nil
+    ctx[i] = (ai ~= nil and ai < nAnims) and ai or NONE16
+  end
+  return ctx
+end
+
+-- ------- naming the animations
+--
+-- build.py's label_animations. The names are not read at runtime -- the mod
+-- addresses animations by index through the move and context tables -- but
+-- they are in the format, so they have to be produced the same way for the
+-- oracle diff to mean anything. They also make a packed file readable in a
+-- hex dump, which is worth the byte apiece.
+
+local function labelAnimations(data, rows, nAux)
+  local anims = data.anims
+  local n = #anims
+  local uses, moveUses = {}, {}
+  local auxOrder, auxCount = {}, {}
+  for i = 1, n do
+    uses[i], moveUses[i] = {}, 0
+    auxOrder[i], auxCount[i] = {}, {}
+  end
+  for e = 0, rows.n - 1 do
+    local ai = rows[e][1]
+    if ai < n then
+      if e < N_MOVES then
+        moveUses[ai + 1] = moveUses[ai + 1] + 1
+      elseif e >= CTX_BASE and e < CTX_BASE + #StadiumBuild.CONTEXTS then
+        local list = uses[ai + 1]
+        list[#list + 1] = StadiumBuild.CONTEXTS[e - CTX_BASE + 1]
+      end
+      local ax = rows[e][2]
+      if ax >= 0 and ax < nAux then
+        local counts, order = auxCount[ai + 1], auxOrder[ai + 1]
+        if counts[ax] == nil then
+          counts[ax] = 0
+          order[#order + 1] = ax
+        end
+        counts[ax] = counts[ax] + 1
+      end
+    end
+  end
+
+  for i = 1, n do
+    -- sorted(set(uses)) -- the alphabetically first context is the fallback
+    -- name, so the ordering is part of the answer
+    local seen, ctx = {}, {}
+    for _, name in ipairs(uses[i]) do
+      if not seen[name] then
+        seen[name] = true
+        ctx[#ctx + 1] = name
+      end
+    end
+    table.sort(ctx)
+    local name = nil
+    for _, pref in ipairs(NAME_PREF) do
+      if seen[pref] then
+        name = pref
+        break
+      end
+    end
+    if not name then
+      if moveUses[i] > 0 then
+        name = "attack"
+      elseif ctx[1] then
+        name = ctx[1]
+      else
+        name = "anim" .. (i - 1)
+      end
+    end
+    anims[i].name = name
+    -- Counter.most_common(1): the highest count, and on a tie the one that
+    -- was inserted first
+    local best, bestN = -1, -1
+    local order, counts = auxOrder[i], auxCount[i]
+    for _, ax in ipairs(order) do
+      if counts[ax] > bestN then
+        best, bestN = ax, counts[ax]
+      end
+    end
+    anims[i].aux = best
+  end
+
+  local seenName = {}
+  for i = 1, n do
+    local base = anims[i].name
+    local k = seenName[base] or 0
+    seenName[base] = k + 1
+    if k > 0 then anims[i].name = base .. "_" .. (k + 1) end
+  end
+end
+
+-- ------- the pack
+
+function StadiumBuild.pack(data, species, moveRows, ctx)
+  local w = newWriter()
+  local bones, prims = data.bones, data.prims
+  local textures, anims, aux = data.textures, data.anims, data.auxAnims
+
+  local height, floorY, radius = stance(data)
+
+  local idleIndex = ctx[1]                    -- CONTEXTS[1] is "idle"
+  local idle = (idleIndex ~= NONE16) and anims[idleIndex + 1] or nil
+  local static = idleIsBroken(data, idle)
+
+  w:raw("DSM3")
+  w:u16(species)
+  w:u16(#bones)
+  w:u16(#prims)
+  w:u16(#textures)
+  w:u16(#anims)
+  w:u16(#aux)
+  w:f32(data.rootScale[1])
+  -- 1 = hold the bind pose, never play an animation
+  w:u8(static and 1 or 0)
+  w:f32(height)
+  w:f32(floorY)
+  w:f32(radius)
+
+  for m = 1, N_MOVES do
+    local row = moveRows[m]
+    w:u16((row and row[1] < #anims) and row[1] or NONE16)
+  end
+  for m = 1, N_MOVES do
+    local row = moveRows[m]
+    w:i16((row and row[2] >= 0 and row[2] < #aux) and row[2] or -1)
+  end
+  for i = 1, #ctx do w:u16(ctx[i]) end
+
+  for i = 1, #bones do
+    local b = bones[i]
+    w:i16(b.parent)
+    for k = 1, 3 do w:i16(roundHalfEven(b.t[k])) end
+    for k = 1, 3 do w:i16(b.r[k]) end
+    for k = 1, 3 do w:i32(fixed(b.s[k])) end
+  end
+
+  for i = 1, #prims do
+    local p = prims[i]
+    w:u16(p.tex)
+    -- the display list's own cull mode: 1024 is G_CULL_BACK
+    w:u8((p.cull and p.cull ~= 0) and 1 or 0)
+    w:u8((p.blend == "add") and 1 or 0)
+    w:i16(p.texAnim or -1)
+    -- sorted by the stream's own byte, which is what the reader keys on
+    local keys = {}
+    if p.texMap then
+      for k in pairs(p.texMap) do keys[#keys + 1] = k end
+      table.sort(keys)
+    end
+    w:u8(#keys)
+    for _, k in ipairs(keys) do
+      w:u8(k)
+      w:u16(p.texMap[k])
+    end
+    local frames = p.fxFrames
+    w:u16(frames and #frames or 0)
+    if frames then
+      for k = 1, #frames do w:u16(frames[k]) end
+    end
+    local pos, uv, nrm, skin = p.pos, p.uv, p.nrm, p.skin
+    w:u16(p.nverts)
+    w:u16(p.nidx)
+    for k = 1, p.nverts do
+      w:i16(pos[k * 3 - 2])
+      w:i16(pos[k * 3 - 1])
+      w:i16(pos[k * 3])
+      -- 1/512, which puts a texel of the largest texture in the set well
+      -- inside a step and still reaches the +-32 the wrapped coordinates of
+      -- some display lists run to
+      w:i16(roundHalfEven(uv[k * 2 - 1] * 512))
+      w:i16(roundHalfEven(uv[k * 2] * 512))
+      w:i8(roundHalfEven(nrm[k * 3 - 2] * 127))
+      w:i8(roundHalfEven(nrm[k * 3 - 1] * 127))
+      w:i8(roundHalfEven(nrm[k * 3] * 127))
+      w:u8(skin[k])
+    end
+    for k = 1, p.nidx do w:u16(p.idx[k]) end
+  end
+
+  for i = 1, #textures do
+    local t = textures[i]
+    w:u16(t.w)
+    w:u16(t.h)
+    w:u32(#t.rgba)
+    w:raw(t.rgba)
+  end
+
+  local REST = { t = { 0, 0, 0 }, r = { 0, 0, 0 }, s = { 1.0, 1.0, 1.0 } }
+  for i = 1, #anims do
+    local a = anims[i]
+    local name = a.name or ""
+    if #name > 255 then name = name:sub(1, 255) end
+    w:u8(#name)
+    w:raw(name)
+    w:u16(a.frames)
+    w:u16(a.loopStart or 0)
+    w:i16(a.aux or -1)
+    for bi = 1, #bones do
+      local tr = a.tracks[bi]
+      if not tr then
+        w:u8(0)
+      else
+        w:u8(1)
+        for _, key in ipairs({ "t", "r", "s" }) do
+          local comps = tr[key]
+          if comps == nil then
+            -- a bone the animation leaves at its rest value for this path:
+            -- written as three constants so the reader never has to branch on
+            -- a missing path
+            comps = bones[bi][key] or REST[key]
+          end
+          for c = 1, 3 do writeTrackComponent(w, comps[c], key) end
+        end
+      end
+    end
+  end
+
+  for i = 1, #aux do
+    local a = aux[i]
+    w:u16(a.frames)
+    w:u16(a.loopStart or 0)
+    w:u16(#a.channels)
+    for _, ch in ipairs(a.channels) do
+      w:u16(ch.n)
+      for k = 1, ch.n do w:u16(ch[k]) end
+    end
+  end
+
+  return w:bytes(), height, floorY, radius
+end
+
+-- ------- one species, end to end
+
+-- The same three steps build.py takes: parse the fragment, label the
+-- animations off the species' battle table, then hang the generated fire/gas
+-- stand-ins on the bones the game's own effect callbacks hang off.
+function StadiumBuild.species(rom, fileno)
+  local blob = rom:model(fileno)
+  if not blob then return nil, ("file %d is not in the archive"):format(fileno) end
+  local data, err = StadiumFragment.extract(blob, ("%d.bin"):format(fileno))
+  if not data then return nil, err end
+  local species = data.species
+  local rows = rom:battleRows(species)
+  labelAnimations(data, rows, #data.auxAnims)
+  StadiumFx.attach(data, species)
+
+  local moveRows = {}
+  for m = 1, N_MOVES do moveRows[m] = rows[m - 1] end
+  local ctx = StadiumBuild.contextTable(rows, #data.anims)
+  local bytes, height, floorY, radius =
+    StadiumBuild.pack(data, species, moveRows, ctx)
+  return { species = species, bytes = bytes, height = height,
+           floor = floorY, radius = radius, bones = #data.bones,
+           prims = #data.prims, anims = #data.anims,
+           warnings = data.warnings }
+end
+
+-- ------- the stepped job
+--
+-- `write(species, bytes)` is called for each finished pack and must answer
+-- truthy; anything else stops the job with an error. Returning a job rather
+-- than taking a callback for progress keeps the caller in charge of when work
+-- happens, which is what lets a loading screen stay responsive.
+function StadiumBuild.job(rom, write, count)
+  local total = count or StadiumRom.N_POKEMON
+  local n = rom:modelCount()
+  if total > n then total = n end
+  local job = { total = total, done = 0, bytes = 0, failed = {}, species = nil }
+
+  function job:step()
+    if self.done >= self.total then return false end
+    local fileno = self.done
+    local ok, res, err = pcall(StadiumBuild.species, rom, fileno)
+    if ok and res then
+      local wrote, wErr = write(res.species, res.bytes)
+      if not wrote then
+        self.error = wErr or ("could not write species " .. res.species)
+        self.done = self.total
+        return false
+      end
+      self.bytes = self.bytes + #res.bytes
+      self.species = res.species
+    else
+      self.failed[#self.failed + 1] = fileno
+      self.lastError = ok and err or res
+    end
+    self.done = self.done + 1
+    return self.done < self.total
+  end
+
+  function job:progress()
+    if self.total <= 0 then return 1 end
+    return self.done / self.total
+  end
+
+  return job
+end
+
+return StadiumBuild
diff --git a/lib/StadiumFragment.lua b/lib/StadiumFragment.lua
new file mode 100644
index 0000000..9cf3565
--- /dev/null
+++ b/lib/StadiumFragment.lua
@@ -0,0 +1,1016 @@
+-- STADIUM battles: one FRAGMENT module -> geometry, textures, skeleton,
+-- animations.
+--
+-- A port of model_extract/pipeline/fragment.py, structure for structure and
+-- name for name, so the two can be read side by side and diffed. The Python
+-- is the reference implementation and stays the ORACLE:
+-- tools/stadium_pack.py drives it over the same ROM and
+-- tests/stadium_extract_test.lua requires the finished .dsm files to agree
+-- byte for byte. That is the only way a port of this much numeric code can
+-- be trusted -- reading it twice is not enough.
+--
+-- ------- the three things it does
+--
+-- THE GEO LAYOUT is a little bytecode: a tree of nodes with an open/close
+-- stack, where some nodes are bones, some set the current material, and some
+-- hand a display list to whichever bone is live. Walking it builds the
+-- skeleton and decides which triangles belong to which bone.
+--
+-- THE DISPLAY LISTS are F3DEX2, the N64's own graphics microcode. Only four
+-- of its commands matter here -- load vertices, draw triangle(s), set the
+-- geometry mode (which carries the cull bits), call/branch -- because
+-- everything else is render state this rebuilds from the texture table
+-- instead.
+--
+-- THE ANIMATIONS are packed per-frame streams of 12- or 16-bit fields. The
+-- game also has a hermite-keyframe mode (flags & 8); it is ported for
+-- completeness but no animation of any of the 151 battle Pokemon uses it.
+--
+-- ------- arithmetic, and why there are no bit operations here
+--
+-- Every shift and mask is written as multiply/divide/modulo. Two reasons.
+-- LuaJIT's `bit` library works on SIGNED 32-bit integers, so every value that
+-- crosses 0x80000000 -- which pointers in this file constantly do -- would
+-- need a conversion back, and each of those is a place to be wrong. And the
+-- one shift that genuinely needs care (`bitfield` below) has to be exact to
+-- 32 bits, which a double can do only if the value is reduced BEFORE it is
+-- shifted up. Doing it in arithmetic makes that reduction visible instead of
+-- hiding it behind an operator that silently truncates.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local StadiumFragment = {}
+
+local byte = string.byte
+local char = string.char
+local sub = string.sub
+local concat = table.concat
+local floor = math.floor
+
+-- Where the module is linked to run. Every pointer inside it is an address in
+-- that space, so subtracting this turns one into a file offset.
+local BASE = 0x8FF00000
+
+-- Size of each geo-layout command, by opcode. A command this does not
+-- understand is still stepped over correctly, which is what lets an unknown
+-- one be a warning rather than the end of the walk.
+local CMD_SIZES = {
+  [0x00] = 0x08, [0x01] = 0x04, [0x02] = 0x08, [0x03] = 0x08, [0x04] = 0x04,
+  [0x05] = 0x04, [0x06] = 0x04, [0x07] = 0x08, [0x08] = 0x0C, [0x09] = 0x04,
+  [0x0A] = 0x08, [0x0B] = 0x18, [0x0C] = 0x04, [0x0D] = 0x04, [0x0E] = 0x04,
+  [0x0F] = 0x04, [0x10] = 0x04, [0x11] = 0x04, [0x12] = 0x04, [0x13] = 0x08,
+  [0x14] = 0x0C, [0x15] = 0x0C, [0x16] = 0x04, [0x17] = 0x14, [0x18] = 0x08,
+  [0x19] = 0x08, [0x1A] = 0x04, [0x1B] = 0x10, [0x1C] = 0x10, [0x1D] = 0x1C,
+  [0x1E] = 0x08, [0x1F] = 0x18, [0x20] = 0x14, [0x21] = 0x10, [0x22] = 0x08,
+  [0x23] = 0x10, [0x24] = 0x04, [0x25] = 0x04, [0x26] = 0x14,
+}
+
+StadiumFragment.CMD_SIZES = CMD_SIZES
+
+-- ------- rounding
+--
+-- Python rounds half to EVEN, and the packer's numbers go through `round`
+-- three times over (fold a track to a constant, quantise a scale to 16.16,
+-- quantise a UV to 1/512). Rounding half away from zero instead would put
+-- this one off by one wherever a value lands exactly on a boundary, and a
+-- single byte is all it takes for the oracle diff to fail -- so it is worth
+-- the six lines to do it the same way.
+
+local function roundHalfEven(x)
+  local f = floor(x)
+  local d = x - f
+  if d > 0.5 then return f + 1 end
+  if d < 0.5 then return f end
+  if f % 2 == 0 then return f end
+  return f + 1
+end
+
+local function roundTo(x, nd)
+  if nd == 0 then return roundHalfEven(x) end
+  local m = 10 ^ nd
+  return roundHalfEven(x * m) / m
+end
+
+StadiumFragment.roundHalfEven = roundHalfEven
+StadiumFragment.roundTo = roundTo
+
+-- Two's-complement reading of an n-bit field.
+local function signed(v, bits)
+  local m = 2 ^ (bits - 1)
+  if v >= m then return v - m * 2 end
+  return v
+end
+
+StadiumFragment.signed = signed
+
+-- The distinct values of an array, in the order they first appear. The Python
+-- reached for a set here and that decided the order textures were registered
+-- in -- see fragment.py's own `unique`.
+local function unique(seq, n)
+  local seen, out, m = {}, {}, 0
+  for i = 1, n or #seq do
+    local v = seq[i]
+    if v ~= nil and not seen[v] then
+      seen[v] = true
+      m = m + 1
+      out[m] = v
+    end
+  end
+  return out, m
+end
+
+-- ------- the module
+
+local Frag = {}
+Frag.__index = Frag
+
+function Frag:u8(o)
+  return byte(self.d, o + 1)
+end
+
+function Frag:s8(o)
+  local v = byte(self.d, o + 1)
+  if v >= 128 then return v - 256 end
+  return v
+end
+
+function Frag:u16(o)
+  local a, b = byte(self.d, o + 1, o + 2)
+  return a * 256 + b
+end
+
+function Frag:s16(o)
+  local a, b = byte(self.d, o + 1, o + 2)
+  local v = a * 256 + b
+  if v >= 32768 then return v - 65536 end
+  return v
+end
+
+function Frag:u32(o)
+  local a, b, c, d = byte(self.d, o + 1, o + 4)
+  return ((a * 256 + b) * 256 + c) * 256 + d
+end
+
+function Frag:s32(o)
+  local v = self:u32(o)
+  if v >= 2147483648 then return v - 4294967296 end
+  return v
+end
+
+-- A pointer as a file offset, or nil for the null pointer.
+function Frag:off(ptr)
+  if ptr == 0 then return nil end
+  return ptr - BASE
+end
+
+function Frag:ptr(o)
+  return self:off(self:u32(o))
+end
+
+-- The entry stub ends with `lui rX, hi; addiu rX, rX, lo`, which is MIPS for
+-- "load the address of the root struct". Finding that pair is how the root is
+-- located without a symbol table.
+function Frag:root()
+  for o = 0x20, 0x7C, 4 do
+    local w = self:u32(o)
+    if floor(w / 0x4000000) == 0x0F then              -- lui
+      local reg = floor(w / 0x10000) % 0x20
+      local w2 = self:u32(o + 4)
+      if floor(w2 / 0x4000000) == 0x09                -- addiu rX, rX, imm
+          and floor(w2 / 0x200000) % 0x20 == reg then
+        return self:u16(o + 2) * 65536 + self:s16(o + 6) - BASE
+      end
+    end
+  end
+  return nil
+end
+
+-- A null-terminated run of pointers.
+function Frag:ptrList(o)
+  local out, n = {}, 0
+  while true do
+    local p = self:ptr(o)
+    if p == nil then return out, n end
+    n = n + 1
+    out[n] = p
+    o = o + 4
+  end
+end
+
+-- `data` is one decompressed archive entry.
+function StadiumFragment.open(data, name)
+  if type(data) ~= "string" or #data < 0x20 then
+    return nil, (name or "?") .. ": too short to be a module"
+  end
+  if sub(data, 9, 16) ~= "FRAGMENT" then
+    return nil, (name or "?") .. ": not a FRAGMENT module"
+  end
+  return setmetatable({ d = data, name = name or "<bytes>" }, Frag)
+end
+
+-- ------- walking the geo layout and running the display lists
+
+local Model = {}
+Model.__index = Model
+
+local function newModel(frag)
+  local r = frag:root()
+  if not r then return nil, frag.name .. ": could not locate root struct" end
+  local geo = frag:ptr(r + 0x08)
+  local anims = frag:ptr(r + 0x0C)
+  local aux = frag:ptr(r + 0x10)
+  local m = setmetatable({
+    f = frag,
+    species = frag:u16(r),
+    geoLayouts = geo and frag:ptrList(geo) or {},
+    anims = anims and frag:ptrList(anims) or {},
+    auxAnims = aux and frag:ptrList(aux) or {},
+    textures = {},          -- { fmt, siz, w, h, texels, data }
+    tluts = {},             -- palettes: { count, data, dl }
+    bones = {},             -- { parent, boneId, flags, chan, t, r, s }
+    boneById = {},
+    prims = {},             -- { tex, tlut, mat, texAnim, cull, verts, tris }
+    primsByKey = {},
+    rootScale = { 1.0, 1.0, 1.0 },
+    fx = {},                -- geo cmd 0x08 procedural effect nodes
+    warnings = {},
+  }, Model)
+  return m
+end
+
+function Model:readTextureTable(off, count)
+  local f = self.f
+  local t = self.textures
+  for i = 0, count - 1 do
+    local o = off + i * 0xC
+    t[#t + 1] = {
+      fmt = f:u8(o), siz = f:u8(o + 1), w = f:s16(o + 2),
+      h = f:u16(o + 4), texels = f:u16(o + 6), data = f:ptr(o + 8),
+    }
+  end
+end
+
+-- Palettes reuse the texture-record layout: the count sits in the width
+-- field, the palette data in the next word, and unk_08 is the display list
+-- that loads it (src/12D80.c func_80015B20). The list is authoritative, so it
+-- is run rather than trusted.
+function Model:readTlutTable(off, count)
+  local f = self.f
+  for i = 0, count - 1 do
+    local o = off + i * 0xC
+    local rec = { count = f:u16(o + 2), data = f:ptr(o + 4), dl = f:ptr(o + 8) }
+    local dl = rec.dl
+    if dl ~= nil then
+      for _ = 1, 16 do
+        local w0, w1 = f:u32(dl), f:u32(dl + 4)
+        local op = floor(w0 / 0x1000000)
+        if op == 0xFD then                            -- G_SETTIMG
+          rec.data = f:off(w1)
+        elseif op == 0xF0 then                        -- G_LOADTLUT
+          rec.count = floor(w1 / 0x4000) % 0x400 + 1
+        elseif op == 0xDF then
+          break
+        end
+        dl = dl + 8
+      end
+    end
+    self.tluts[#self.tluts + 1] = rec
+  end
+end
+
+-- The bone whose matrix is live. Mirrors gCurGraphNodeList in
+-- src/geo_layout.c: the top of the stack is the slot the NEXT node command
+-- writes to, so the node that owns the current one is the slot below
+-- (func_80017AC4).
+function Model:curBone()
+  local n = #self.stack
+  if n >= 2 then return self.stack[n - 1] end
+  return -1
+end
+
+function Model:build()
+  self.curTex = -1
+  self.curTlut = -1
+  self.curMat = nil
+  self.curTexAnim = -1
+  self.stack = { -1 }
+  -- The RSP vertex cache persists ACROSS display lists: a bone's list often
+  -- preloads vertices that the next bone's list then indexes, which is how
+  -- these models get blended joints. Each slot remembers the bone whose
+  -- matrix was current when it was loaded.
+  self.vbuf = {}
+  if not self.geoLayouts[1] then
+    self.warnings[#self.warnings + 1] = "no geo layout"
+    return
+  end
+  self:walk(self.geoLayouts[1], 0)
+end
+
+function Model:walk(o, depth)
+  local f = self.f
+  if depth > 32 or o == nil then return end
+  while true do
+    local cmd = f:u8(o)
+    local size = CMD_SIZES[cmd]
+    if size == nil then
+      self.warnings[#self.warnings + 1] =
+        ("unknown geo cmd 0x%02x at 0x%x"):format(cmd or -1, o)
+      return
+    end
+    if cmd == 0x01 or cmd == 0x04 then                -- end / return
+      return
+    elseif cmd == 0x00 or cmd == 0x03 then            -- branch (with return)
+      self:walk(f:ptr(o + 4), depth + 1)
+    elseif cmd == 0x02 then                           -- jump (no return)
+      o = f:ptr(o + 4)
+      if o == nil then return end
+      cmd = nil                                       -- skip the o = o + size
+    elseif cmd == 0x05 then                           -- open node
+      local n = #self.stack
+      self.stack[n + 1] = self.stack[n]
+    elseif cmd == 0x06 then                           -- close node
+      self.stack[#self.stack] = nil
+    elseif cmd == 0x17 then                           -- model header
+      self:readTextureTable(f:ptr(o + 8), f:s16(o + 2))
+      if f:ptr(o + 0xC) then
+        self:readTlutTable(f:ptr(o + 0xC), f:s16(o + 4))
+      end
+      self.vtxBase = f:ptr(o + 0x10)
+      self.nVerts = f:s16(o + 6)
+    elseif cmd == 0x08 then                           -- effect callback
+      self.fx[#self.fx + 1] = { bone = self:curBone(), callback = f:u32(o + 4),
+                                arg = f:ptr(o + 8) }
+    elseif cmd == 0x1C then                           -- uniform scale node
+      self.rootScale = { f:s32(o + 4) / 65536.0, f:s32(o + 8) / 65536.0,
+                         f:s32(o + 0xC) / 65536.0 }
+    elseif cmd == 0x1D then                           -- bone / joint node
+      local idx = #self.bones                         -- 0-based, as the file
+      self.bones[idx + 1] = {
+        parent = self:curBone(), boneId = f:u8(o + 1), flags = f:u8(o + 2),
+        chan = f:s8(o + 3),
+        t = { f:s16(o + 4), f:s16(o + 6), f:s16(o + 8) },
+        r = { f:s16(o + 0xA), f:s16(o + 0xC), f:s16(o + 0xE) },
+        s = { f:s32(o + 0x10) / 65536.0, f:s32(o + 0x14) / 65536.0,
+              f:s32(o + 0x18) / 65536.0 },
+      }
+      self.boneById[f:u8(o + 1)] = idx
+      self.stack[#self.stack] = idx
+    elseif cmd == 0x23 then                           -- set texture / material
+      self.curTex = f:s16(o + 8)
+      self.curTlut = f:s16(o + 0xA)
+      self.curMat = f:ptr(o + 4)
+      -- offset 0x02 is the texture-animation channel; -1 means static.
+      -- func_800176DC swaps this material's texture per frame out of the
+      -- auxiliary animation's stream.
+      self.curTexAnim = f:s16(o + 2)
+    elseif cmd == 0x22 then                           -- DL on current bone
+      self:runDL(f:ptr(o + 4), self:curBone(), 0)
+    elseif cmd == 0x1E then                           -- DL on named bone
+      local named = self.boneById[f:s16(o + 2)]
+      self:runDL(f:ptr(o + 4), named or self:curBone(), 0)
+    elseif cmd == 0x20 or cmd == 0x21 then            -- DL + own transform
+      self:runDL(f:ptr(o + (cmd == 0x20 and 0x10 or 0xC)), self:curBone(), 0)
+    end
+    if cmd ~= nil then o = o + size end
+  end
+end
+
+function Model:primFor(tex, tlut, mat, texAnim, cull)
+  local key = tex .. "," .. tlut .. "," .. tostring(mat) .. ","
+              .. texAnim .. "," .. cull
+  local p = self.primsByKey[key]
+  if p == nil then
+    p = { tex = tex, tlut = tlut, mat = mat, texAnim = texAnim, cull = cull,
+          verts = {}, nverts = 0, tris = {}, ntris = 0, remap = {} }
+    self.primsByKey[key] = p
+    self.prims[#self.prims + 1] = p
+  end
+  return p
+end
+
+-- Bitwise AND and OR over the low `bits` bits, in arithmetic. See the module
+-- header for why there is no `bit` library in here.
+local function band(a, b, bits)
+  local r, p = 0, 1
+  for _ = 1, bits do
+    if a % 2 == 1 and b % 2 == 1 then r = r + p end
+    a, b, p = floor(a / 2), floor(b / 2), p * 2
+  end
+  return r
+end
+
+local function bor(a, b, bits)
+  local r, p = 0, 1
+  for _ = 1, bits do
+    if a % 2 == 1 or b % 2 == 1 then r = r + p end
+    a, b, p = floor(a / 2), floor(b / 2), p * 2
+  end
+  return r
+end
+
+-- One triangle out of the vertex cache and into a prim, deduplicating
+-- vertices as it goes.
+--
+-- A file-level function rather than a closure inside runDL: it is called
+-- once per triangle -- some two hundred thousand times over the set -- and a
+-- closure there would allocate one per display-list command.
+--
+-- A triangle naming a cache slot that was never loaded is DROPPED, not
+-- faulted. That happens where a display list indexes past what its own G_VTX
+-- filled, which the hardware would read as stale cache; there is nothing
+-- meaningful to draw.
+--
+-- It is abandoned MID-TRIANGLE, exactly where the missing corner is found,
+-- and the corners already walked keep the slots they were just given in the
+-- vertex list. That looks like a detail and is not: those vertices stay in
+-- the prim, so they shift every later vertex's index by one. Checking all
+-- three corners up front instead -- which is what a careful reader writes --
+-- silently produces a different, equally plausible vertex list, and every
+-- index in the file after that point moves.
+local function emit(prim, vbuf, flip, ia, ib, ic)
+  local remap, verts = prim.remap, prim.verts
+  local tri = { 0, 0, 0 }
+  for k = 1, 3 do
+    local v = vbuf[k == 1 and ia or (k == 2 and ib or ic)]
+    if v == nil then return end
+    local key = v[1] .. "," .. v[2] .. "," .. v[3] .. "," .. v[4] .. ","
+                .. v[5] .. "," .. v[6] .. "," .. v[7] .. "," .. v[8]
+                .. "," .. v[9] .. "," .. v[10]
+    local j = remap[key]
+    if j == nil then
+      j = prim.nverts                                 -- 0-based, as the file
+      remap[key] = j
+      prim.nverts = j + 1
+      verts[j + 1] = v
+    end
+    tri[k] = j
+  end
+  if flip then tri[1], tri[3] = tri[3], tri[1] end
+  prim.ntris = prim.ntris + 1
+  prim.tris[prim.ntris] = tri
+end
+
+function Model:runDL(o, bone, depth)
+  if o == nil or depth > 8 then return end
+  local f = self.f
+  local vbuf = self.vbuf
+  local cull = 0x400
+  while true do
+    local w0, w1 = f:u32(o), f:u32(o + 4)
+    local op = floor(w0 / 0x1000000)
+    o = o + 8
+    if op == 0xDF then                                -- G_ENDDL
+      return
+    elseif op == 0xDE then                            -- G_DL
+      self:runDL(f:off(w1), bone, depth + 1)
+      if floor(w0 / 0x10000) % 256 ~= 0 then          -- branch, not call
+        return
+      end
+    elseif op == 0x01 then                            -- G_VTX
+      local n = floor(w0 / 0x1000) % 256
+      local v0 = floor((w0 % 0x1000) / 2) - n
+      local a = f:off(w1)
+      if a then
+        for i = 0, n - 1 do
+          local p = a + i * 0x10
+          local slot = v0 + i
+          if slot >= 0 and slot < 64 then
+            -- s and t are S10.5 and kept RAW here; the /32 happens once, on
+            -- the way out, so that the dedup key below compares integers
+            vbuf[slot] = { f:s16(p), f:s16(p + 2), f:s16(p + 4),
+                           f:s16(p + 8), f:s16(p + 10),
+                           f:s8(p + 12), f:s8(p + 13), f:s8(p + 14),
+                           f:u8(p + 15), bone }
+          end
+        end
+      end
+    elseif op == 0xD9 then                            -- G_GEOMETRYMODE
+      -- `cull = (cull & (w0 & 0xFFFFFF)) | w1`.
+      --
+      -- Only bits 9 and 10 -- G_CULL_FRONT and G_CULL_BACK -- are ever read
+      -- out of this, so the whole word is kept to eleven bits and the AND and
+      -- OR done over those. Bits above that cannot influence bits 9 and 10
+      -- under either operator, so nothing is lost and the two loops that used
+      -- to run 24 times each now run 11.
+      cull = bor(band(cull, w0 % 0x800, 11), w1 % 0x800, 11)
+    elseif op == 0x05 or op == 0x06 then              -- G_TRI1 / G_TRI2
+      local prim = self:primFor(self.curTex, self.curTlut, self.curMat,
+                                self.curTexAnim, cull % 0x800 - cull % 0x200)
+      local flip = (floor(cull / 0x200) % 2 == 1)
+                   and (floor(cull / 0x400) % 2 == 0)
+      emit(prim, vbuf, flip,
+           floor(floor(w0 / 0x10000) % 256 / 2),
+           floor(floor(w0 / 0x100) % 256 / 2),
+           floor(w0 % 256 / 2))
+      if op == 0x06 then
+        emit(prim, vbuf, flip,
+             floor(floor(w1 / 0x10000) % 256 / 2),
+             floor(floor(w1 / 0x100) % 256 / 2),
+             floor(w1 % 256 / 2))
+      end
+    end
+    -- everything else (SETTILE / sync / ...) is render state this rebuilds
+    -- from the texture table instead, so it is skipped
+  end
+end
+
+-- CI4 selects a 16-entry block of the palette through the render tile's
+-- palette field; read it out of the material display list's last G_SETTILE.
+function Model:tilePalette(mat)
+  if mat == nil then return 0 end
+  local f = self.f
+  local pal = 0
+  for _ = 1, 16 do
+    local w0, w1 = f:u32(mat), f:u32(mat + 4)
+    local op = floor(w0 / 0x1000000)
+    if op == 0xF5 and floor(w1 / 0x1000000) % 8 == 0 then   -- render tile
+      pal = floor(w1 / 0x100000) % 16
+    elseif op == 0xDF then
+      break
+    end
+    mat = mat + 8
+  end
+  return pal
+end
+
+-- ------- animations
+
+-- src/F420.c func_80010500: a signed `bits`-wide field at bit index*bits.
+--
+-- C integer division truncates toward zero and Lua's floor() does not, which
+-- only differs for a NEGATIVE index -- and a negative index is exactly what an
+-- empty channel produces, so the truncating form is the one that matches the
+-- hardware.
+--
+-- The shift is the part that needs care. `(v << rem) & 0xFFFFFFFF` on a
+-- 32-bit v would want 63 bits of intermediate, and a double holds 53 exactly.
+-- Masking FIRST -- dropping the bits the shift would push out anyway -- keeps
+-- the product under 2^32 and therefore exact.
+local function bitfield(f, base, index, bits)
+  local bitpos = index * bits
+  local word
+  if bitpos >= 0 then
+    word = floor(bitpos / 16)
+  else
+    word = -floor(-bitpos / 16)
+  end
+  local rem = bitpos - word * 16
+  local o = base + word * 2
+  local v = f:u16(o) * 65536 + f:u16(o + 2)
+  local shift = rem % 32
+  local keep = 2 ^ (32 - shift)
+  v = (v % keep) * 2 ^ shift
+  return signed(floor(v / 2 ^ (32 - bits)), bits)
+end
+
+StadiumFragment.bitfield = bitfield
+
+local Anim = {}
+Anim.__index = Anim
+
+local function newAnim(frag, off)
+  return setmetatable({
+    f = frag, off = off,
+    flags      = frag:u8(off),
+    startFrame = frag:u16(off + 4),
+    loopStart  = frag:u16(off + 6),
+    nChannels  = frag:u16(off + 8),
+    nFrames    = frag:u16(off + 0xA),
+    chanTable  = frag:ptr(off + 0xC),
+    scaleData  = frag:ptr(off + 0x10),
+    rotData    = frag:ptr(off + 0x14),
+    transData  = frag:ptr(off + 0x18),
+  }, Anim)
+end
+
+function Anim:chan(i)
+  local o = self.chanTable + i * 0xA
+  local f = self.f
+  return { nScale = f:u8(o), nRot = f:u8(o + 1), nTrans = f:u8(o + 2),
+           interp = f:u8(o + 3), oScale = f:u16(o + 4),
+           oRot = f:u16(o + 6), oTrans = f:u16(o + 8) }
+end
+
+-- Packed per-frame streams (flags & 8 == 0), which is what every animation of
+-- every battle Pokemon actually uses.
+--
+-- A count of 0 means the component has no stream at all. The game's own index
+-- arithmetic (offset + count - 1) then runs off the front of the array -- for
+-- Tangela's idle the scale "array" is two entries long and the computed index
+-- is 99 -- so an empty channel is read as "keep the bind-pose value", which is
+-- what the nil return means to the caller.
+function Anim:transPacked(c, frame)
+  if c.nTrans == 0 then return nil end
+  local wide = floor(self.flags / 4) % 2 == 1
+  local bits = wide and 16 or 12
+  if c.nTrans == 1 then
+    if wide then return c.oTrans + 0.0 end
+    return floor(signed(c.oTrans * 16 % 65536, 16) / 16) + 0.0
+  end
+  local i = c.oTrans + (frame < c.nTrans - 1 and frame or c.nTrans - 1)
+  return bitfield(self.f, self.transData, i, bits) + 0.0
+end
+
+function Anim:rotPacked(c, frame)
+  if c.nRot == 0 then return nil end
+  if c.nRot == 1 then return signed(c.oRot * 16 % 65536, 16) end
+  local i = c.oRot + (frame < c.nRot - 1 and frame or c.nRot - 1)
+  return signed(bitfield(self.f, self.rotData, i, 12) * 16 % 65536, 16)
+end
+
+function Anim:scalePacked(c, frame)
+  if c.nScale == 0 then return nil end
+  if c.nScale == 1 then return c.oScale / 1000.0 end
+  local i = c.oScale + (frame < c.nScale - 1 and frame or c.nScale - 1)
+  return self.f:s16(self.scaleData + i * 2) / 1000.0
+end
+
+-- Hermite keyframes (flags & 8). Ported for completeness: no animation of any
+-- of the 151 battle Pokemon sets that flag, which was measured rather than
+-- assumed.
+function Anim:hermite(base, n, frame, wide)
+  local f = self.f
+  local stride = wide and 8 or 6
+  local function key(i)
+    local o = base + i * stride
+    return f:s16(o), f:s16(o + 2), f:s16(o + 4),
+           wide and f:s16(o + 6) or f:s16(o + 4)
+  end
+  local k0t, k0v = key(0)
+  if k0t >= frame then return k0v + 0.0 end
+  local lt, lv = key(n - 1)
+  if frame >= lt then return lv + 0.0 end
+  local i = 0
+  while i < n - 2 do
+    if frame < (key(i + 1)) then break end
+    i = i + 1
+  end
+  local at, av, ao, aw = key(i)
+  local bt, bv, bo = key(i + 1)
+  local x = (frame - at) / 30.0
+  local y = 30.0 / (bt - at)
+  local x2, x3 = x * x, x * x * x
+  local y2, y3 = y * y, y * y * y
+  return av * (2 * x3 * y3 - 3 * x2 * y2 + 1)
+         + bv * (-2 * x3 * y3 + 3 * x2 * y2)
+         + (wide and aw or ao) * (x3 * y2 - 2 * x2 * y + x)
+         + bo * (x3 * y2 - x2 * y)
+end
+
+function Anim:transKey(c, frame)
+  if c.nTrans < 2 then return signed(c.oTrans, 16) + 0.0 end
+  return self:hermite(self.transData + c.oTrans * 2, c.nTrans, frame,
+                      c.interp % 2 == 1)
+end
+
+function Anim:rotKey(c, frame)
+  local deg
+  if c.nRot < 2 then
+    deg = signed(c.oRot, 16) / 10.0
+  else
+    deg = self:hermite(self.rotData + c.oRot * 2, c.nRot, frame,
+                       floor(c.interp / 2) % 2 == 1) / 10.0
+  end
+  deg = deg % 360.0
+  return floor(deg / 360.0 * 65536.0)
+end
+
+function Anim:scaleKey(c, frame)
+  if c.nScale < 2 then return signed(c.oScale, 16) / 100.0 end
+  return self:hermite(self.scaleData + c.oScale * 2, c.nScale, frame,
+                      floor(c.interp / 4) % 2 == 1) / 100.0
+end
+
+-- (translation, rotation, scale) for one bone at one frame. Components whose
+-- channel carries no data fall back to the bone's bind value.
+function Anim:sampleTrs(chanIndex, frame, bt, br, bs)
+  local base = chanIndex * 3
+  if base < 0 or base + 2 >= self.nChannels then return nil end
+  local c1, c2, c3 = self:chan(base), self:chan(base + 1), self:chan(base + 2)
+  local t, r, s = {}, {}, {}
+  if floor(self.flags / 8) % 2 == 1 then
+    t[1], t[2], t[3] = self:transKey(c1, frame), self:transKey(c2, frame),
+                       self:transKey(c3, frame)
+    r[1], r[2], r[3] = self:rotKey(c1, frame), self:rotKey(c2, frame),
+                       self:rotKey(c3, frame)
+    s[1], s[2], s[3] = self:scaleKey(c1, frame), self:scaleKey(c2, frame),
+                       self:scaleKey(c3, frame)
+  else
+    t[1], t[2], t[3] = self:transPacked(c1, frame), self:transPacked(c2, frame),
+                       self:transPacked(c3, frame)
+    r[1], r[2], r[3] = self:rotPacked(c1, frame), self:rotPacked(c2, frame),
+                       self:rotPacked(c3, frame)
+    s[1], s[2], s[3] = self:scalePacked(c1, frame),
+                       self:scalePacked(c2, frame), self:scalePacked(c3, frame)
+  end
+  for k = 1, 3 do
+    if t[k] == nil then t[k] = bt[k] end
+    if r[k] == nil then r[k] = br[k] end
+    if s[k] == nil then s[k] = bs[k] end
+  end
+  return t, r, s
+end
+
+-- Texture animation (src/18140.c). Same header shape as a skeletal animation,
+-- but each channel is a per-frame stream of texture-table indices that
+-- func_800176DC substitutes into a material.
+local Aux = {}
+Aux.__index = Aux
+
+local function newAux(frag, off)
+  return setmetatable({
+    f = frag,
+    flags      = frag:u8(off),
+    startFrame = frag:u16(off + 4),
+    loopStart  = frag:u16(off + 6),
+    nChannels  = frag:u16(off + 8),
+    nFrames    = frag:u16(off + 0xA),
+    chanTable  = frag:ptr(off + 0xC),
+    data       = frag:ptr(off + 0x10),
+  }, Aux)
+end
+
+-- func_80017540: index into the stream, clamped to the channel's length.
+function Aux:sample(chan, frame)
+  if chan < 0 or chan >= self.nChannels or self.chanTable == nil then
+    return nil
+  end
+  local o = self.chanTable + chan * 4
+  local count, base = self.f:u16(o), self.f:u16(o + 2)
+  if count == 0 then return nil end
+  local i = base + (frame < count and frame or count - 1)
+  return self.f:u8(self.data + i)
+end
+
+function Aux:track(chan)
+  local n = self.nFrames > 1 and self.nFrames or 1
+  local out = {}
+  for i = 0, n - 1 do out[i + 1] = self:sample(chan, i) end
+  return out, n
+end
+
+-- ------- textures
+
+local function rgba5551(p)
+  return floor(floor(p / 2048) % 32 * 255 / 31),
+         floor(floor(p / 64) % 32 * 255 / 31),
+         floor(floor(p / 2) % 32 * 255 / 31),
+         (p % 2 == 1) and 255 or 0
+end
+
+-- (w, h, RGBA8 string) for the N64 texture formats these models use.
+local function decodeTexture(f, tex, tlut, palette)
+  local w, h, fmt, siz, addr = tex.w, tex.h, tex.fmt, tex.siz, tex.data
+  local n = w * h
+  if n <= 0 or addr == nil then
+    return w, h, string.rep("\255\0\255\255", n > 0 and n or 0)
+  end
+  local d = f.d
+  local out = {}
+
+  local function nibble(i)
+    local v = byte(d, addr + floor(i / 2) + 1)
+    if i % 2 == 1 then return v % 16 end
+    return floor(v / 16)
+  end
+
+  if fmt == 0 and siz == 2 then                       -- RGBA16 (5/5/5/1)
+    for i = 0, n - 1 do
+      local a, b = byte(d, addr + i * 2 + 1, addr + i * 2 + 2)
+      out[i + 1] = char(rgba5551(a * 256 + b))
+    end
+  elseif fmt == 0 and siz == 3 then                   -- RGBA32
+    return w, h, sub(d, addr + 1, addr + n * 4)
+  elseif fmt == 2 then                                -- CI4 / CI8
+    local pal, np = {}, 0
+    if tlut ~= nil and tlut.data ~= nil then
+      local base = tlut.data + (siz == 0 and palette * 16 * 2 or 0)
+      np = (siz == 0) and 16 or 256
+      for i = 0, np - 1 do
+        local a, b = byte(d, base + i * 2 + 1, base + i * 2 + 2)
+        pal[i + 1] = char(rgba5551(a * 256 + b))
+      end
+    end
+    if np == 0 then
+      np = 256
+      for i = 1, np do pal[i] = "\255\0\255\255" end
+    end
+    for i = 0, n - 1 do
+      local idx = (siz == 0) and nibble(i) or byte(d, addr + i + 1)
+      out[i + 1] = pal[idx % np + 1]
+    end
+  elseif fmt == 3 then                                -- IA16 / IA8 / IA4
+    for i = 0, n - 1 do
+      local l, a
+      if siz == 2 then
+        local x, y = byte(d, addr + i * 2 + 1, addr + i * 2 + 2)
+        l, a = x, y
+      elseif siz == 1 then
+        local v = byte(d, addr + i + 1)
+        l, a = floor(v / 16) * 17, v % 16 * 17
+      else
+        local v = nibble(i)
+        l, a = floor(floor(v / 2) * 255 / 7), (v % 2 == 1) and 255 or 0
+      end
+      out[i + 1] = char(l, l, l, a)
+    end
+  elseif fmt == 4 then                                -- I8 / I4
+    for i = 0, n - 1 do
+      local l = (siz == 1) and byte(d, addr + i + 1) or nibble(i) * 17
+      out[i + 1] = char(l, l, l, 255)
+    end
+  else
+    for i = 0, n - 1 do out[i + 1] = "\255\0\255\255" end
+  end
+  return w, h, concat(out)
+end
+
+StadiumFragment.decodeTexture = decodeTexture
+
+-- ------- the whole model
+
+-- Constant tracks collapse to a scalar; most channels never move, and that
+-- fold is most of the reason the packed set is 34 megabytes rather than a
+-- great deal more.
+local function compress(values, n, nd)
+  local first = roundTo(values[1], nd)
+  for i = 2, n do
+    if roundTo(values[i], nd) ~= first then
+      local out = {}
+      for k = 1, n do out[k] = roundTo(values[k], nd) end
+      return out
+    end
+  end
+  return first
+end
+
+-- Every distinct effect callback in the geo layout, once, in the order it
+-- first appears -- see fragment.py's dedupe_fx for why the order is written
+-- down rather than left to a hash table.
+local function dedupeFx(nodes)
+  local seen, out = {}, {}
+  for i = 1, #nodes do
+    local node = nodes[i]
+    local key = node.bone .. "," .. node.callback .. "," .. tostring(node.arg)
+    if not seen[key] then
+      seen[key] = true
+      out[#out + 1] = { bone = node.bone, callback = node.callback,
+                        arg = node.arg }
+    end
+  end
+  return out
+end
+
+-- One decompressed archive entry -> the table StadiumBuild packs. Mirrors
+-- fragment.extract, including which fields exist and what they are named.
+function StadiumFragment.extract(data, name)
+  local frag, err = StadiumFragment.open(data, name)
+  if not frag then return nil, err end
+  local m, mErr = newModel(frag)
+  if not m then return nil, mErr end
+  m:build()
+
+  local auxAnims = {}
+  for i = 1, #m.auxAnims do auxAnims[i] = newAux(frag, m.auxAnims[i]) end
+
+  local texIndexMap, texOut = {}, {}
+
+  local function register(texIdx, tlut, pal)
+    local key = texIdx .. "," .. tlut .. "," .. pal
+    local hit = texIndexMap[key]
+    if hit ~= nil then return hit end
+    if texIdx < 0 or texIdx >= #m.textures then return -1 end
+    local slot = #texOut                              -- 0-based, as the file
+    texIndexMap[key] = slot
+    local tl = (tlut >= 0 and tlut < #m.tluts) and m.tluts[tlut + 1] or nil
+    local w, h, rgba = decodeTexture(frag, m.textures[texIdx + 1], tl, pal)
+    texOut[slot + 1] = { index = texIdx, w = w, h = h, rgba = rgba }
+    return slot
+  end
+
+  -- an animated material can swap to any texture its channel names, so all of
+  -- them have to be decoded up front
+  for _, p in ipairs(m.prims) do
+    if p.ntris > 0 then
+      local pal = m:tilePalette(p.mat)
+      register(p.tex, p.tlut, pal)
+      if p.texAnim >= 0 then
+        for _, a in ipairs(auxAnims) do
+          local tr, tn = a:track(p.texAnim)
+          local vals, vn = unique(tr, tn)
+          for i = 1, vn do register(vals[i], p.tlut, pal) end
+        end
+      end
+    end
+  end
+
+  local prims = {}
+  for _, p in ipairs(m.prims) do
+    if p.ntris > 0 then
+      local pal = m:tilePalette(p.mat)
+      local ti = texIndexMap[p.tex .. "," .. p.tlut .. "," .. pal] or -1
+      -- texture-table index -> slot in texOut, for the animated swap
+      local texMap = nil
+      if p.texAnim >= 0 then
+        for _, a in ipairs(auxAnims) do
+          local tr, tn = a:track(p.texAnim)
+          local vals, vn = unique(tr, tn)
+          for i = 1, vn do
+            local slot = texIndexMap[vals[i] .. "," .. p.tlut .. "," .. pal]
+            if slot ~= nil then
+              texMap = texMap or {}
+              texMap[vals[i]] = slot
+            end
+          end
+        end
+      end
+      local tw, th = 32, 32
+      if ti >= 0 then
+        tw, th = m.textures[p.tex + 1].w, m.textures[p.tex + 1].h
+      end
+      local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {}
+      for i = 1, p.nverts do
+        local v = p.verts[i]
+        pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3] = v[1], v[2], v[3]
+        uv[i * 2 - 1] = (v[4] / 32.0) / tw
+        uv[i * 2] = (v[5] / 32.0) / th
+        nrm[i * 3 - 2] = v[6] / 127.0
+        nrm[i * 3 - 1] = v[7] / 127.0
+        nrm[i * 3] = v[8] / 127.0
+        skin[i] = v[10]
+      end
+      local ni = 0
+      for i = 1, p.ntris do
+        local tri = p.tris[i]
+        idx[ni + 1], idx[ni + 2], idx[ni + 3] = tri[1], tri[2], tri[3]
+        ni = ni + 3
+      end
+      prims[#prims + 1] = {
+        tex = ti, cull = p.cull, texAnim = p.texAnim, texMap = texMap,
+        pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = p.nverts,
+        idx = idx, nidx = ni,
+      }
+    end
+  end
+
+  local anims = {}
+  for i = 1, #m.anims do
+    local a = newAnim(frag, m.anims[i])
+    local nf = a.nFrames > 1 and a.nFrames or 1
+    local tracks = {}
+    for bi = 1, #m.bones do
+      local b = m.bones[bi]
+      local ch = b.chan
+      if ch >= 0 and a:sampleTrs(ch, 0, b.t, b.r, b.s) ~= nil then
+        local ts, rs, ss = {}, {}, {}
+        for k = 1, 3 do ts[k], rs[k], ss[k] = {}, {}, {} end
+        for fr = 0, nf - 1 do
+          local t, r, s = a:sampleTrs(ch, fr, b.t, b.r, b.s)
+          for k = 1, 3 do
+            ts[k][fr + 1], rs[k][fr + 1], ss[k][fr + 1] = t[k], r[k], s[k]
+          end
+        end
+        tracks[bi] = {
+          t = { compress(ts[1], nf, 3), compress(ts[2], nf, 3),
+                compress(ts[3], nf, 3) },
+          r = { compress(rs[1], nf, 0), compress(rs[2], nf, 0),
+                compress(rs[3], nf, 0) },
+          s = { compress(ss[1], nf, 5), compress(ss[2], nf, 5),
+                compress(ss[3], nf, 5) },
+        }
+      end
+    end
+    anims[i] = { index = i - 1, frames = nf, flags = a.flags,
+                 channels = a.nChannels, loopStart = a.loopStart,
+                 tracks = tracks }
+  end
+
+  local auxOut = {}
+  for i = 1, #auxAnims do
+    local a = auxAnims[i]
+    local chans = {}
+    for c = 0, a.nChannels - 1 do
+      local tr, tn = a:track(c)
+      tr.n = tn
+      chans[c + 1] = tr
+    end
+    auxOut[i] = { index = i - 1, frames = a.nFrames > 1 and a.nFrames or 1,
+                  flags = a.flags, loopStart = a.loopStart, channels = chans }
+  end
+
+  return {
+    species = m.species,
+    file = name,
+    rootScale = m.rootScale,
+    bones = m.bones,
+    textures = texOut,
+    prims = prims,
+    anims = anims,
+    auxAnims = auxOut,
+    fx = dedupeFx(m.fx),
+    warnings = m.warnings,
+  }
+end
+
+return StadiumFragment
diff --git a/lib/StadiumFx.lua b/lib/StadiumFx.lua
new file mode 100644
index 0000000..1f60de4
--- /dev/null
+++ b/lib/StadiumFx.lua
@@ -0,0 +1,436 @@
+-- STADIUM battles: the generated fire and gas stand-ins.
+--
+-- A port of model_extract/pipeline/effects.py, plus the bind-pose measurement
+-- build.py sizes them against.
+--
+-- IMPORTANT: nothing here is extracted game data. The real tail flame, mane
+-- fire and gas are drawn by procedural callbacks that live in a different
+-- fragment -- geo command 0x08 records an attachment point and
+-- func_80014A60 calls node->unk_10, and the model file supplies only two
+-- empty display lists plus zeroed scratch buffers for it to fill. Those
+-- callbacks have not been ported, so the models genuinely contain no flame
+-- mesh and no flame texture: Charmander's texture set is eyes, claws, teeth
+-- and skin.
+--
+-- What follows is an ORIGINAL, procedurally generated replacement -- looping
+-- flipbook noise on a pair of crossed quads, anchored to the exact bone the
+-- callback hangs off so it sits where the real effect would and follows the
+-- animation. Seeds derive from the species number, so a given Pokemon always
+-- generates the same flame.
+--
+-- Which species get one is the game's own grouping: every species sharing a
+-- callback shares an effect.
+--
+--   0x810000D8  Charmander, Charmeleon, Charizard, Magmar, Moltres   tail flame
+--   0x81000108  Ponyta, Rapidash, Moltres's wings                    small flame
+--   0x810000E0  Gastly (only)                                        gas cloud
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local StadiumFx = {}
+
+local floor = math.floor
+local sqrt = math.sqrt
+local sin, cos = math.sin, math.cos
+local char = string.char
+local concat = table.concat
+local pi = math.pi
+
+local FIRE_TAIL  = 0x810000D8
+local FIRE_SMALL = 0x81000108
+local AURA       = 0x810000E0
+
+-- Desired size as a fraction of the model's world-space height: length, width.
+StadiumFx.SIZES = {
+  fire_tail  = { 0.40, 0.22 },
+  fire_small = { 0.075, 0.042 },
+  gas        = { 1.05, 1.05 },
+}
+
+-- ------- 32-bit exclusive-or, in arithmetic
+--
+-- The generator below is an xorshift, so it needs a real 32-bit xor and a
+-- real 32-bit wrap. Written out rather than taken from LuaJIT's `bit`, which
+-- works in SIGNED 32-bit and would need converting back on every step -- see
+-- the same note in StadiumFragment.
+
+local function bxor32(a, b)
+  local r, p = 0, 1
+  for _ = 1, 32 do
+    local x, y = a % 2, b % 2
+    if x ~= y then r = r + p end
+    a, b, p = floor(a / 2), floor(b / 2), p * 2
+  end
+  return r
+end
+
+-- ------- deterministic noise
+
+local Rng = {}
+Rng.__index = Rng
+
+local function newRng(seed)
+  local s = seed % 0x100000000
+  if s == 0 then s = 0x9E3779B9 end
+  return setmetatable({ s = s }, Rng)
+end
+
+function Rng:next()
+  local x = self.s
+  x = bxor32(x, (x % 0x80000) * 0x2000)               -- x ^= (x << 13)
+  x = bxor32(x, floor(x / 0x20000))                   -- x ^= x >> 17
+  x = bxor32(x, (x % 0x8000000) * 0x20)               -- x ^= (x << 5)
+  self.s = x % 0x100000000
+  return self.s
+end
+
+function Rng:unit()
+  return self:next() / 0x100000000
+end
+
+-- A w-by-h lattice of unit noise, consumed row by row so the sequence -- and
+-- therefore the texture -- is reproducible.
+local function lattice(rng, w, h)
+  local g = {}
+  for y = 1, h do
+    local row = {}
+    for x = 1, w do row[x] = rng:unit() end
+    g[y] = row
+  end
+  return g
+end
+
+local function smooth(t)
+  return t * t * (3 - 2 * t)
+end
+
+-- Bilinear value noise on a torus, so the field tiles in both axes.
+local function sample(grid, x, y)
+  local h = #grid
+  local w = #grid[1]
+  local fx0, fy0 = floor(x), floor(y)
+  local x0, y0 = fx0 % w, fy0 % h
+  local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
+  local fx, fy = smooth(x - fx0), smooth(y - fy0)
+  local r0, r1 = grid[y0 + 1], grid[y1 + 1]
+  local a = r0[x0 + 1] + (r0[x1 + 1] - r0[x0 + 1]) * fx
+  local b = r1[x0 + 1] + (r1[x1 + 1] - r1[x0 + 1]) * fx
+  return a + (b - a) * fy
+end
+
+-- Sum octaves of tileable noise.
+local function fbm(grids, x, y, scale)
+  local total, amp, norm = 0.0, 1.0, 0.0
+  for i = 1, #grids do
+    local f = scale * 2 ^ (i - 1)
+    total = total + sample(grids[i], x * f, y * f) * amp
+    norm = norm + amp
+    amp = amp * 0.5
+  end
+  return total / norm
+end
+
+-- Intensity -> RGBA, through a piecewise ramp.
+local function ramp(stops, t)
+  if t < 0.0 then t = 0.0 elseif t > 1.0 then t = 1.0 end
+  for i = 1, #stops - 1 do
+    local a, b = stops[i], stops[i + 1]
+    if t <= b[1] then
+      local k = 0.0
+      if b[1] ~= a[1] then k = (t - a[1]) / (b[1] - a[1]) end
+      return floor(a[2] + (b[2] - a[2]) * k), floor(a[3] + (b[3] - a[3]) * k),
+             floor(a[4] + (b[4] - a[4]) * k), floor(a[5] + (b[5] - a[5]) * k)
+    end
+  end
+  local last = stops[#stops]
+  return last[2], last[3], last[4], last[5]
+end
+
+local FIRE_RAMP = {
+  { 0.00, 0, 0, 0, 0 },
+  { 0.30, 120, 24, 8, 90 },
+  { 0.52, 226, 78, 16, 205 },
+  { 0.74, 252, 176, 44, 245 },
+  { 1.00, 255, 246, 214, 255 },
+}
+
+local GAS_RAMP = {
+  { 0.00, 0, 0, 0, 0 },
+  { 0.34, 52, 26, 78, 70 },
+  { 0.60, 96, 52, 140, 140 },
+  { 0.82, 148, 96, 196, 190 },
+  { 1.00, 208, 176, 236, 215 },
+}
+
+local TRANSPARENT = char(0, 0, 0, 0)
+
+-- An upward-advected noise plume. Scrolling by an exact multiple of the
+-- lattice over the frame count is what makes the loop seamless.
+local function fireFrames(seed, w, h, frames, wisp)
+  wisp = wisp or 1.0
+  local rng = newRng(seed)
+  local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
+                  lattice(rng, 32, 32) }
+  local out = {}
+  for f = 0, frames - 1 do
+    local t = f / frames
+    local buf = {}
+    for i = 1, w * h do buf[i] = TRANSPARENT end
+    for y = 0, h - 1 do
+      local v = y / (h - 1)                 -- 0 at the base, 1 at the tip
+      -- plume envelope: wide and hot at the base, pinched at the tip
+      local taper = 1.0 - v
+      if taper < 0.0 then taper = 0.0 end
+      taper = taper ^ 0.42
+      for x = 0, w - 1 do
+        local u = (x / (w - 1)) * 2 - 1     -- -1 .. 1 across the flame
+        local denom = taper * 0.95
+        if denom < 0.10 then denom = 0.10 end
+        local radial = 1.0 - (u < 0 and -u or u) / denom
+        if radial > 0 then
+          radial = radial ^ 0.7
+          local n = fbm(grids, x / w, (y / h) - t, 3.0)
+          local lick = 0.55 + 0.75 * (n - 0.5) * wisp
+          local inten = radial * (0.55 + 0.8 * taper) * lick
+          inten = inten - 0.16 * v           -- cool towards the tip
+          if inten > 0.02 then
+            local r, g, b, a = ramp(FIRE_RAMP, inten)
+            -- +Y in texture space is up
+            buf[(h - 1 - y) * w + x + 1] = char(r, g, b, a)
+          end
+        end
+      end
+    end
+    out[f + 1] = concat(buf)
+  end
+  return w, h, out
+end
+
+-- Slow swirling haze that fades out towards the rim.
+local function gasFrames(seed, w, h, frames)
+  local rng = newRng(seed)
+  local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
+                  lattice(rng, 32, 32) }
+  local out = {}
+  for f = 0, frames - 1 do
+    local t = f / frames
+    local buf = {}
+    for i = 1, w * h do buf[i] = TRANSPARENT end
+    local ang = t * 2 * pi
+    local ca, sa = cos(ang), sin(ang)
+    for y = 0, h - 1 do
+      for x = 0, w - 1 do
+        local dx = (x / (w - 1)) * 2 - 1
+        local dy = (y / (h - 1)) * 2 - 1
+        local d = sqrt(dx * dx + dy * dy)
+        if d < 1.0 then
+          local falloff = (1.0 - d) ^ 0.85
+          -- rotate the sample point so the haze churns without popping
+          local sx = dx * ca - dy * sa
+          local sy = dx * sa + dy * ca
+          local n = fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
+          local inten = falloff * (0.78 + 1.30 * (n - 0.44))
+          if inten > 0.03 then
+            local r, g, b, a = ramp(GAS_RAMP, inten)
+            buf[y * w + x + 1] = char(r, g, b, a)
+          end
+        end
+      end
+    end
+    out[f + 1] = concat(buf)
+  end
+  return w, h, out
+end
+
+-- Two quads at right angles, so the effect reads from any angle. `axis` picks
+-- which bone-local direction the quad grows along: bone-local +X runs down the
+-- limb, so a flame laid out along X comes out lying sideways, and 'y' is that
+-- same quad turned a quarter left about Z, which stands it up. `centred`
+-- straddles the origin instead of growing from it.
+local function crossedQuads(bone, length, width, axis, centred)
+  local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {}
+  local ST = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } }
+  local nv, ni = 0, 0
+  for q = 0, 1 do
+    local base = nv
+    for k = 1, 4 do
+      local s, t = ST[k][1], ST[k][2]
+      local a = (s - 0.5) * width
+      local b = centred and (t - 0.5) * length or t * length
+      local px, py, pz
+      if axis == "x" then
+        if q == 0 then px, py, pz = b, a, 0.0 else px, py, pz = b, 0.0, a end
+      else                                       -- (x, y) -> (-y, x)
+        if q == 0 then px, py, pz = -a, b, 0.0 else px, py, pz = 0.0, b, a end
+      end
+      pos[nv * 3 + 1], pos[nv * 3 + 2], pos[nv * 3 + 3] = px, py, pz
+      uv[nv * 2 + 1], uv[nv * 2 + 2] = s, 1.0 - t
+      if q == 0 then
+        nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 0.0, 0.0, 1.0
+      else
+        nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 1.0, 0.0, 0.0
+      end
+      skin[nv + 1] = bone
+      nv = nv + 1
+    end
+    idx[ni + 1], idx[ni + 2], idx[ni + 3] = base, base + 1, base + 2
+    idx[ni + 4], idx[ni + 5], idx[ni + 6] = base, base + 2, base + 3
+    ni = ni + 6
+  end
+  return { pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = nv,
+           idx = idx, nidx = ni }
+end
+
+-- ------- the bind pose these are sized against
+--
+-- build.py's bind_extent, kept in its own 4x4 column-major convention rather
+-- than folded into StadiumBuild's 3x4 walk. The two agree -- they are the
+-- same skeleton -- but the effect sizes come out of THIS one's per-bone scale
+-- measurement, and rewriting it into the other convention is exactly the kind
+-- of change that moves a byte without anyone noticing.
+
+local function trs(t, r, s)
+  local function S(v) return sin(v / 32768 * pi) end
+  local function C(v) return cos(v / 32768 * pi) end
+  local sx, cx = S(r[1]), C(r[1])
+  local sy, cy = S(r[2]), C(r[2])
+  local sz, cz = S(r[3]), C(r[3])
+  return { cy * cz * s[1], cy * sz * s[1], -sy * s[1], 0,
+           (sx * sy * cz - cx * sz) * s[2], (sx * sy * sz + cx * cz) * s[2],
+           sx * cy * s[2], 0,
+           (cx * sy * cz + sx * sz) * s[3], (cx * sy * sz - sx * cz) * s[3],
+           cx * cy * s[3], 0,
+           t[1], t[2], t[3], 1 }
+end
+
+local function mul(a, b)
+  local r = {}
+  for c = 0, 3 do
+    for i = 1, 4 do
+      r[c * 4 + i] = a[i] * b[c * 4 + 1] + a[4 + i] * b[c * 4 + 2]
+                     + a[8 + i] * b[c * 4 + 3] + a[12 + i] * b[c * 4 + 4]
+    end
+  end
+  return r
+end
+
+-- (height of the bind pose, per-bone local scale). Height rather than the
+-- largest dimension: sizing off the max would scale Moltres's flames to its
+-- wingspan.
+function StadiumFx.bindExtent(data)
+  local root = trs({ 0, 0, 0 }, { 0, 0, 0 }, data.rootScale)
+  local acc, uns, mats = {}, {}, {}
+  for i = 1, #data.bones do
+    local b = data.bones[i]
+    local p = b.parent
+    local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
+    local pu = (p >= 0) and uns[p + 1] or root
+    local u = mul(pu, trs({ b.t[1] * pa[1], b.t[2] * pa[2], b.t[3] * pa[3] },
+                          b.r, { 1, 1, 1 }))
+    local a = { pa[1] * b.s[1], pa[2] * b.s[2], pa[3] * b.s[3] }
+    local m = {}
+    for k = 1, 16 do m[k] = u[k] end
+    for k = 1, 4 do
+      m[k] = m[k] * a[1]
+      m[4 + k] = m[4 + k] * a[2]
+      m[8 + k] = m[8 + k] * a[3]
+    end
+    acc[i], uns[i], mats[i] = a, u, m
+  end
+
+  local lo = { 1e9, 1e9, 1e9 }
+  local hi = { -1e9, -1e9, -1e9 }
+  for _, prim in ipairs(data.prims) do
+    local pos, skin = prim.pos, prim.skin
+    for i = 1, prim.nverts do
+      local m = mats[skin[i] + 1]
+      if m then
+        local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
+        local wx = m[1] * x + m[5] * y + m[9] * z + m[13]
+        local wy = m[2] * x + m[6] * y + m[10] * z + m[14]
+        local wz = m[3] * x + m[7] * y + m[11] * z + m[15]
+        if wx < lo[1] then lo[1] = wx end
+        if wy < lo[2] then lo[2] = wy end
+        if wz < lo[3] then lo[3] = wz end
+        if wx > hi[1] then hi[1] = wx end
+        if wy > hi[2] then hi[2] = wy end
+        if wz > hi[3] then hi[3] = wz end
+      end
+    end
+  end
+  local extent = (lo[1] <= hi[1]) and (hi[2] - lo[2]) or 1.0
+
+  -- how much each bone scales its own local space, so an effect can divide it
+  -- back out and come out the size it asked for wherever it hangs
+  local scales = {}
+  for i = 1, #mats do
+    local m = mats[i]
+    scales[i] = sqrt(m[1] * m[1] + m[2] * m[2] + m[3] * m[3])
+  end
+  return extent, scales
+end
+
+-- ------- what a species gets
+
+-- Returns a list of { kind, bone, geo, w, h, frames }, or an empty list.
+function StadiumFx.buildFor(species, fx, extent, boneScale)
+  local out = {}
+  for _, node in ipairs(fx) do
+    local cb, bone = node.callback, node.bone
+    if bone >= 0 and bone < #boneScale then
+      local k = boneScale[bone + 1]
+      if k == 0 then k = 1.0 end
+      if cb == FIRE_TAIL then
+        local fl, fw = StadiumFx.SIZES.fire_tail[1], StadiumFx.SIZES.fire_tail[2]
+        local w, h, fr = fireFrames(species * 7919 + 1, 32, 64, 8)
+        out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
+                          geo = crossedQuads(bone, extent * fl / k,
+                                             extent * fw / k, "y", false) }
+      elseif cb == FIRE_SMALL then
+        local fl, fw = StadiumFx.SIZES.fire_small[1],
+                       StadiumFx.SIZES.fire_small[2]
+        local w, h, fr = fireFrames(species * 6271 + bone, 24, 40, 8, 1.25)
+        out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
+                          geo = crossedQuads(bone, extent * fl / k,
+                                             extent * fw / k, "y", false) }
+      elseif cb == AURA and species == 92 then        -- Gastly only
+        local fl, fw = StadiumFx.SIZES.gas[1], StadiumFx.SIZES.gas[2]
+        local w, h, fr = gasFrames(species * 5237 + 3, 48, 48, 10)
+        out[#out + 1] = { kind = "gas", bone = bone, w = w, h = h, frames = fr,
+                          geo = crossedQuads(bone, extent * fl / k,
+                                             extent * fw / k, "y", true) }
+      end
+    end
+  end
+  return out
+end
+
+-- Append the generated prims and their flipbook textures to a model, exactly
+-- as build.py's attach_effects does. Returns how many were made.
+function StadiumFx.attach(data, species)
+  if not (data.fx and #data.fx > 0) then return 0 end
+  local extent, boneScale = StadiumFx.bindExtent(data)
+  local made = StadiumFx.buildFor(species, data.fx, extent, boneScale)
+  for _, e in ipairs(made) do
+    local first = #data.textures                      -- 0-based, as the file
+    for i = 1, #e.frames do
+      data.textures[first + i] = { index = -1, w = e.w, h = e.h,
+                                   generated = true, rgba = e.frames[i] }
+    end
+    local g = e.geo
+    local fxFrames = {}
+    for i = 1, #e.frames do fxFrames[i] = first + i - 1 end
+    data.prims[#data.prims + 1] = {
+      tex = first, cull = 0, texAnim = -1, texMap = nil,
+      generated = true, effect = e.kind,
+      blend = (e.kind == "fire") and "add" or "alpha",
+      fxFrames = fxFrames,
+      pos = g.pos, uv = g.uv, nrm = g.nrm, skin = g.skin, nverts = g.nverts,
+      idx = g.idx, nidx = g.nidx,
+    }
+  end
+  return #made
+end
+
+return StadiumFx
diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua
new file mode 100644
index 0000000..1314e4b
--- /dev/null
+++ b/lib/StadiumInstall.lua
@@ -0,0 +1,261 @@
+-- STADIUM battles: finding the ROM, and building the models out of it once.
+--
+-- The mod does not ship the Pokemon Stadium models and cannot: they are that
+-- game's data. What it ships is the READER -- StadiumRom, StadiumFragment,
+-- StadiumFx and StadiumBuild -- and the player supplies the cartridge, which
+-- is exactly the arrangement this engine already has for the Game Boy ROM it
+-- is a recompilation of (src/import/RomImporter.lua).
+--
+-- So: drop a Pokemon Stadium (US) ROM in `baseroms/`, and the first time the
+-- game runs with the mod on, the models are built. Once, on a loading screen,
+-- in about ten seconds. After that the packs sit in the save directory and
+-- the mod reads them like any other asset.
+--
+-- ------- where "baseroms/" is
+--
+-- One relative path, and it deliberately covers two different places at once,
+-- because PhysFS searches the save directory AND the game folder under the
+-- same names:
+--
+--   * a folder install, or a checkout -- `baseroms/` next to main.lua, which
+--     is also where the decompilation's own `make init` puts it
+--   * a packaged or fused build, where the game folder is inside an archive
+--     and cannot be written to -- `baseroms/` in the save directory, whose
+--     absolute path this reports on screen so it can be found
+--
+-- Any of `.z64`, `.n64` and `.v64` is accepted; StadiumRom normalises the
+-- byte order on load.
+--
+-- ------- what "installed" means
+--
+-- A marker file next to the packs, holding the format magic, how many species
+-- were written and the md5 of the ROM they came from. All three matter. The
+-- magic catches a format change (the packs are rebuilt rather than read as
+-- garbage), the count catches a build that was interrupted half way, and the
+-- md5 catches the player swapping the ROM for a different revision.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local StadiumPack = V.require("StadiumPack")
+
+local StadiumInstall = {}
+
+-- Where a ROM is looked for, and where the built packs are kept.
+StadiumInstall.ROM_DIR = "baseroms"
+StadiumInstall.DIR = StadiumPack.CACHE_DIR
+StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info"
+
+-- Bumped whenever the .dsm format changes, so an old cache is rebuilt rather
+-- than misread. Must track StadiumPack's magic.
+StadiumInstall.FORMAT = "DSM3"
+
+StadiumInstall.COUNT = 151
+
+-- Named ROM files, in the order the decompilation's own pipeline looks for
+-- them, then any ROM at all sitting in the folder.
+local NAMED = {
+  StadiumInstall.ROM_DIR .. "/baserom.z64",
+  StadiumInstall.ROM_DIR .. "/us/baserom.z64",
+  StadiumInstall.ROM_DIR .. "/baserom.n64",
+  StadiumInstall.ROM_DIR .. "/baserom.v64",
+}
+
+local function fs()
+  return love and love.filesystem
+end
+
+local function isFile(path)
+  local f = fs()
+  if not (f and f.getInfo) then return false end
+  local ok, info = pcall(f.getInfo, path, "file")
+  return (ok and info) and true or false
+end
+
+-- The ROM's path on the PhysFS read path, or nil.
+function StadiumInstall.romPath()
+  local f = fs()
+  if not f then return nil end
+  for _, path in ipairs(NAMED) do
+    if isFile(path) then return path end
+  end
+  local ok, items = pcall(f.getDirectoryItems, StadiumInstall.ROM_DIR)
+  if ok and items then
+    table.sort(items)
+    for _, name in ipairs(items) do
+      if name:lower():match("%.[nvz]64$") then
+        local path = StadiumInstall.ROM_DIR .. "/" .. name
+        if isFile(path) then return path end
+      end
+    end
+  end
+  return nil
+end
+
+function StadiumInstall.romPresent()
+  return StadiumInstall.romPath() ~= nil
+end
+
+-- Where to tell the player to put it. The save directory is the answer that
+-- is always writable, and it is the one a packaged build needs.
+function StadiumInstall.romHint()
+  local f = fs()
+  local base = (f and f.getSaveDirectory and select(2, pcall(f.getSaveDirectory)))
+  if type(base) ~= "string" then base = "the game folder" end
+  return base .. "/" .. StadiumInstall.ROM_DIR
+end
+
+-- ------- the marker
+
+local function readMarker()
+  local f = fs()
+  if not (f and isFile(StadiumInstall.MARKER)) then return nil end
+  local ok, text = pcall(f.read, StadiumInstall.MARKER)
+  if not (ok and type(text) == "string") then return nil end
+  local format, count, md5 = text:match("^(%S+)%s+(%d+)%s*(%S*)")
+  if not format then return nil end
+  return { format = format, count = tonumber(count), md5 = md5 }
+end
+
+-- Whether a complete, current set of packs is on disk.
+local readyCache = nil
+
+function StadiumInstall.ready()
+  if readyCache ~= nil then return readyCache end
+  local m = readMarker()
+  readyCache = (m ~= nil and m.format == StadiumInstall.FORMAT
+                and m.count == StadiumInstall.COUNT) and true or false
+  return readyCache
+end
+
+-- Whether a complete set came WITH the mod folder -- a developer checkout
+-- that has run tools/stadium_pack.py. Never true of a released build, which
+-- carries no models at all.
+--
+-- Sampled at both ends of the dex rather than counted. The question being
+-- asked is "did somebody run the packer here", not "is every one of the 151
+-- present"; a genuinely half-written folder is a case for the marker file,
+-- which is what catches an interrupted RUNTIME build.
+local function shipped()
+  local mod = V.mod
+  if not (mod and mod.read) then return false end
+  for _, dex in ipairs({ 1, 151 }) do
+    local ok, bytes = pcall(mod.read, mod,
+                            ("%s/%03d.dsm"):format(StadiumPack.DIR, dex))
+    if not (ok and type(bytes) == "string" and #bytes > 4) then return false end
+  end
+  return true
+end
+
+-- Whether the STADIUM rungs can be offered at all: either the packs have been
+-- built from the player's ROM, or the mod folder already carries a set.
+function StadiumInstall.available()
+  if StadiumInstall.ready() then return true end
+  return shipped()
+end
+
+-- Whether there is work to do: something to build from, and nothing usable
+-- yet.
+--
+-- A checkout that already carries a set is NOT pending. Building anyway would
+-- be correct and would also mean a ten-second loading screen on the first run
+-- of every checkout, to arrive at the files that were already sitting there.
+function StadiumInstall.pending()
+  if StadiumInstall.available() then return false end
+  return StadiumInstall.romPresent()
+end
+
+function StadiumInstall.forget()
+  readyCache = nil
+end
+
+-- ------- building
+
+local job = nil
+local status = { state = "idle", done = 0, total = StadiumInstall.COUNT }
+
+StadiumInstall.status = status
+
+local function writePack(species, bytes)
+  local f = fs()
+  if not f then return false, "no filesystem" end
+  local ok, err = f.write(("%s/%03d.dsm"):format(StadiumInstall.DIR, species),
+                          bytes)
+  if not ok then return false, tostring(err) end
+  return true
+end
+
+-- Open the ROM and start a stepped build. Returns false plus a reason when
+-- there is nothing to build from.
+function StadiumInstall.begin()
+  local f = fs()
+  if not f then return false, "no filesystem" end
+  local path = StadiumInstall.romPath()
+  if not path then return false, "no ROM in " .. StadiumInstall.ROM_DIR end
+
+  local okRead, bytes = pcall(f.read, path)
+  if not (okRead and type(bytes) == "string") then
+    return false, "could not read " .. path
+  end
+
+  local StadiumRom = V.require("StadiumRom")
+  local StadiumBuild = V.require("StadiumBuild")
+  local rom, err = StadiumRom.open(bytes)
+  if not rom then return false, tostring(err) end
+  if not rom:isExpectedUS() then
+    V.mod.log:warn("stadium: %s is md5 %s, not the US 1.0 ROM the model "
+                   .. "offsets are keyed to -- building anyway", path,
+                   tostring(rom:md5()))
+  end
+
+  pcall(f.createDirectory, StadiumInstall.DIR)
+  job = StadiumBuild.job(rom, writePack, StadiumInstall.COUNT)
+  job.md5 = rom:md5()
+  status.state = "building"
+  status.done = 0
+  status.total = job.total
+  status.error = nil
+  return true
+end
+
+-- One species. Returns true while there is more to do.
+function StadiumInstall.step()
+  if not job then return false end
+  local more = job:step()
+  status.done = job.done
+  status.species = job.species
+  if job.error then
+    status.state = "failed"
+    status.error = job.error
+    job = nil
+    return false
+  end
+  if not more then
+    local f = fs()
+    local wrote = #job.failed == 0
+    if wrote and f then
+      pcall(f.write, StadiumInstall.MARKER,
+            ("%s %d %s\n"):format(StadiumInstall.FORMAT, job.total,
+                                  tostring(job.md5 or "")))
+      readyCache = nil
+      StadiumPack.forget()
+    end
+    if not wrote then
+      status.state = "failed"
+      status.error = ("%d of %d models could not be built")
+                     :format(#job.failed, job.total)
+    else
+      status.state = "done"
+    end
+    job = nil
+    return false
+  end
+  return true
+end
+
+function StadiumInstall.cancel()
+  job = nil
+  status.state = "idle"
+end
+
+return StadiumInstall
diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua
new file mode 100644
index 0000000..7374aa9
--- /dev/null
+++ b/lib/StadiumMon.lua
@@ -0,0 +1,350 @@
+-- STADIUM battles: one Pokemon, standing on its tile.
+--
+-- The side's live state -- which species is out, the rig posing it, which
+-- animation the fight has asked for and how far through it is, and the
+-- matrix that puts it on its cell at the right size facing the right way.
+-- Stadium owns the pair of these; StadiumRig owns the arithmetic.
+--
+-- ------- how big a Pokemon is
+--
+-- The one genuinely invented number in this mode, and it is worth saying
+-- why it is invented rather than measured.
+--
+-- The flat 2D-3D mode has an exact answer: a full-size 56-pixel pic covers
+-- one 16-pixel overworld square, so a canvas pixel is a fixed number of
+-- world pixels and every species comes out at whatever its own artwork's
+-- size implies (see BattleBillboard.FULL_W). The camera is then SOLVED to
+-- make one square that big on screen (BattleCam).
+--
+-- The Stadium models have no such anchor. Their units are the N64's, they
+-- run from Caterpie at 9 units to Gyarados at 147 -- a sixteenfold spread,
+-- where the Gen 1 pics span barely one and a half -- and the game they come
+-- from framed each one with its own camera, which a fight staged on the
+-- overworld cannot do because the two mons share a shot.
+--
+-- Taken literally, that spread puts Caterpie at a couple of pixels on a
+-- 144-pixel screen while Gyarados leaves the frame. So the range is
+-- COMPRESSED rather than either honoured or discarded: a species is drawn
+-- at REF_HEIGHT world pixels scaled by its own height over the set's
+-- median, raised to SQUASH. At 1 that would be the raw sixteenfold spread;
+-- at 0 every Pokemon would be the same size; at 0.55 the order and the
+-- feel of the differences survive -- Onix and Gyarados tower, Diglett and
+-- Caterpie are small enough to have to look for -- inside a range a shared
+-- frame can hold.
+--
+-- ------- and where its feet are
+--
+-- The pack measures each model's lowest point against its own origin
+-- (tools/stadium_pack.py's `stance`), and the answer splits the set in
+-- three. 119 species sit within 5% of zero: the origin IS the floor, and
+-- the game stood them on its field with it. A handful sit ABOVE it --
+-- Zubat, Magnemite, Geodude -- which is a hover the model is authored with.
+-- The rest hang BELOW it -- Tentacruel, Gastly, Haunter, Weezing, Zapdos --
+-- which is a model centred on its origin rather than standing on it.
+--
+-- So a model is stood on its own lowest point, and then given back as much
+-- of its authored hover as the shot can hold -- HOVER_CAP of its own height,
+-- no more. The middle group is unaffected either way, which is the check
+-- that the rule is reading the data rather than correcting it.
+--
+-- The cap is not tidiness. Stadium framed one Pokemon per camera and could
+-- afford to hang Zubat three body-heights off the floor; this shot has the
+-- foe's feet on GB row 56 of 144, so the same hover puts Zubat off the top
+-- of the frame entirely -- which is exactly what it did before the cap. The
+-- flat 2D-3D mode has the same constraint and answers it by bottom-aligning
+-- every pic, hovering species included; this keeps the hover but spends
+-- only the room there is.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Mat4 = V.require("Mat4")
+local StadiumPack = V.require("StadiumPack")
+local StadiumRig = V.require("StadiumRig")
+
+local StadiumMon = {}
+StadiumMon.__index = StadiumMon
+
+-- How tall a median Pokemon stands, in world pixels.
+--
+-- Not picked by eye: it is what the FLAT mode already puts on those cells.
+-- A full-size Gen 1 pic is 56 pixels for the foe and 64 for the player's
+-- own, drawn with its feet on GB rows 56 and 96 of a 144-row frame -- so a
+-- full-size mon covers 39% of the frame at the far cell and 44% at the near
+-- one. Against the lens BattleCam solves (about 38 world pixels of frame at
+-- the far cell, 30 at the near one, because the near one is closer) both of
+-- those work out at roughly fourteen world pixels.
+--
+-- So this is the number that makes a median Stadium model exactly as big as
+-- the artwork it replaces, which is what keeps the composition the camera
+-- was solved for.
+StadiumMon.REF_HEIGHT = 14
+
+-- The set's own median bind height, in game units (tools/stadium_pack.py
+-- --report prints it). Only ever a reference point for the ratio above, so
+-- a re-extraction that moved it slightly changes nothing but the middle of
+-- the ladder.
+StadiumMon.MEDIAN = 52.25
+
+-- How much of the raw size spread survives. See the header.
+StadiumMon.SQUASH = 0.5
+
+-- And hard stops either end, because a compression is not a guarantee. The
+-- ceiling is what keeps Onix and Gyarados inside a frame whose top edge is
+-- only 56 GB rows above the foe's own feet: past about this they stop being
+-- imposing and start being cropped.
+StadiumMon.MIN_HEIGHT = 5
+StadiumMon.MAX_HEIGHT = 18
+
+-- How much of an authored hover survives, as a fraction of the Pokemon's
+-- own height. See the header: Stadium could hang a flier three body-heights
+-- up because it framed one Pokemon at a time.
+StadiumMon.HOVER_CAP = 0.5
+
+-- The animation clock. Every animation in the set is authored at 30 fps
+-- (model_extract/README.md), and the eyes run on their own counter at the
+-- same rate.
+StadiumMon.FPS = StadiumPack.FPS
+
+-- ------- the animation the fight is asking for
+--
+-- Each entry says which context slot to look up, whether it loops, and
+-- what it falls back to when the species has no animation in that slot.
+local STATES = {
+  idle = { slot = "idle", loop = true },
+  entrance = { slot = "entrance", loop = false, next = "idle" },
+  hit = { slot = "hit", loop = false, next = "idle" },
+  flinch = { slot = "flinch", loop = false, next = "idle", fallback = "hit" },
+  faint = { slot = "faint", loop = false, hold = true },
+  -- an attack names its animation outright (the move table decides), so it
+  -- has no slot of its own
+  attack = { loop = false, next = "idle" },
+}
+
+function StadiumMon.new(side)
+  return setmetatable({
+    side = side,               -- "player" or "enemy"
+    species = nil,             -- the dex number currently modelled
+    model = nil,
+    rig = nil,
+    state = "idle",
+    anim = nil,                -- index into model.anims
+    time = 0,                  -- seconds into it
+    loop = true,
+    hold = false,
+    aux = nil,                 -- the texture animation running alongside
+    visible = false,
+    scale = 1,                 -- the send-out grow, 1 the rest of the time
+  }, StadiumMon)
+end
+
+function StadiumMon:release()
+  if self.rig then self.rig:release() end
+  self.rig, self.model, self.species = nil, nil, nil
+end
+
+-- ------- which species this side is showing
+--
+-- Returns true when the model is ready to draw. A species with no pack, or
+-- one whose meshes would not build, answers false -- and Stadium then
+-- leaves that side to the flat card, which is a per-POKEMON decline rather
+-- than a per-battle one: a fight can perfectly well have a model on one
+-- side and a pic on the other.
+function StadiumMon:setSpecies(dex)
+  if dex == self.species then return self.rig ~= nil end
+  if self.rig then self.rig:release() end
+  self.rig, self.model, self.species = nil, nil, dex
+  if not dex then return false end
+  local model = StadiumPack.load(dex)
+  if not model then return false end
+  local rig = StadiumRig.new(model)
+  if not rig then return false end
+  self.model, self.rig = model, rig
+  -- a new Pokemon on the field opens on its standby loop; whoever sent it
+  -- out asks for the entrance a moment later
+  self.state, self.anim, self.time = nil, nil, 0
+  self:play("idle")
+  return true
+end
+
+-- ------- the state machine
+
+-- Which animation a context slot resolves to for this species, or nil.
+function StadiumMon:slotAnim(name)
+  local model = self.model
+  local slot = model and StadiumPack.SLOT[name]
+  if not slot then return nil end
+  local index = model.ctx[slot]
+  if not index or index == StadiumPack.NONE then return nil end
+  return index + 1
+end
+
+-- Start a state. `animIndex` overrides the state's own slot lookup, which
+-- is what an attack uses.
+function StadiumMon:play(state, animIndex, auxIndex)
+  local model = self.model
+  if not model then return false end
+  local def = STATES[state] or STATES.idle
+  local index = animIndex
+  if not index and def.slot then index = self:slotAnim(def.slot) end
+  if not index and def.fallback then index = self:slotAnim(def.fallback) end
+  if not index then
+    -- the species has nothing for this; the standby loop is always there
+    if state == "idle" then index = 1 else return self:play("idle") end
+  end
+  local anim = model.anims[index]
+  if not anim then return false end
+
+  self.state, self.anim, self.time = state, index, 0
+  -- A species whose animations are corrupt at source stands in its BIND
+  -- pose and does not move. The state machine still runs -- the fight is
+  -- still asking for a hit or a faint, and something may want to know --
+  -- but nothing is ever sampled, so the Pokemon simply stands there
+  -- looking like itself, which is the one thing the broken data cannot do.
+  if model.staticPose then self.anim = nil end
+  self.loop = def.loop and true or false
+  self.hold = def.hold and true or false
+  -- The eyes that go with it. Every skeletal animation carries the texture
+  -- animation the battle table most often set alongside it (the pack's own
+  -- `aux`), and a move may name a different one -- a hit that leaves the
+  -- Pokemon confused swaps the open eye for the dizzy swirl.
+  self.aux = auxIndex or anim.aux
+  return true
+end
+
+-- Ask for a state, but never interrupt one that outranks it. A faint is
+-- final, and a hit reaction landing on top of an attack the Pokemon is
+-- halfway through reads as the attack being cancelled -- which, on the
+-- receiving end of a two-hit turn, it is not.
+local RANK = { idle = 0, entrance = 1, attack = 2, hit = 3, flinch = 3,
+               faint = 4 }
+
+function StadiumMon:request(state, animIndex, auxIndex)
+  if not self.model then return false end
+  local now = RANK[self.state] or 0
+  local want = RANK[state] or 0
+  if self.state == "faint" then return false end
+  -- an equal-ranked request RESTARTS: a second hit in a turn should play
+  -- the flinch again rather than be swallowed by the first
+  if want < now then return false end
+  return self:play(state, animIndex, auxIndex)
+end
+
+-- The animation a move plays for this species, from the battle system's own
+-- per-species table (model_extract's moves.json, packed into the .dsm).
+-- `moveIndex` is the Gen 1 move id, which the engine's move defs carry as
+-- `index` -- the same numbering, so no name mapping is needed.
+function StadiumMon:attack(moveIndex)
+  local model = self.model
+  if not (model and moveIndex and moveIndex >= 1
+          and moveIndex <= StadiumPack.N_MOVES) then
+    return false
+  end
+  local index = model.moveAnim[moveIndex]
+  if not index or index == StadiumPack.NONE then return false end
+  local aux = model.moveAux[moveIndex]
+  return self:request("attack", index + 1,
+                      (aux and aux >= 0) and (aux + 1) or nil)
+end
+
+-- ------- per frame
+
+function StadiumMon:update(dt)
+  local model = self.model
+  if not (model and self.anim) then return end
+  local anim = model.anims[self.anim]
+  if not anim then return end
+  self.time = self.time + (dt or 0)
+  if self.time >= anim.seconds and not self.loop then
+    if self.hold then
+      -- a faint stays down: hold the last frame rather than snapping back
+      -- to a standing pose the moment the animation runs out
+      self.time = math.max(0, anim.seconds - 1 / StadiumMon.FPS)
+    else
+      local nextState = (STATES[self.state] or {}).next or "idle"
+      self:play(nextState)
+    end
+  end
+end
+
+-- How tall this species stands on the map, in world pixels.
+function StadiumMon:worldHeight()
+  local model = self.model
+  local h = model and model.height or 0
+  if not (h > 0) then return StadiumMon.REF_HEIGHT end
+  local k = (h / StadiumMon.MEDIAN) ^ StadiumMon.SQUASH
+  local out = StadiumMon.REF_HEIGHT * k
+  if out < StadiumMon.MIN_HEIGHT then out = StadiumMon.MIN_HEIGHT end
+  if out > StadiumMon.MAX_HEIGHT then out = StadiumMon.MAX_HEIGHT end
+  return out
+end
+
+-- How wide this Pokemon stands, in world pixels -- the same scale
+-- worldHeight is in, so a caller can size something to its footprint.
+--
+-- Only STADIUM B asks: it needs to know how big a platform to put under a
+-- mon, and "as tall as it is" is the wrong answer for a Snorlax, which is
+-- half as tall as an Onix and three times as wide.
+--
+-- The send-out grow is deliberately NOT folded in. A Pokemon scaling up out
+-- of its ball should arrive on a platform that was already there, not one
+-- that inflates under its feet.
+function StadiumMon:worldRadius()
+  local model = self.model
+  if not model then return 0 end
+  local h = model.height or 0
+  if not (h > 0) then return 0 end
+  return (model.radius or 0) * self:worldHeight() / h
+end
+
+-- The model matrix: stand this Pokemon on world (x, groundY, z) facing
+-- (faceX, faceZ), at whatever the send-out grow has done to its size.
+--
+-- The vertices the rig writes are in the model's RAW units -- before the
+-- model_root scale the game applies -- so the scale here carries that too,
+-- and the floor offset is measured in the same raw units on the way in.
+function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
+  local model = self.model
+  if not model then return nil end
+  local root = model.rootScale
+  if not (root and root > 0) then root = 1 end
+  local k = root * self:worldHeight() / math.max(model.height, 1e-6)
+  k = k * (self.scale or 1)
+  -- stand it on its own lowest point, then give back as much of the
+  -- authored hover as the shot can hold (see the header)
+  local floor = model.floor or 0
+  local hover = math.min(math.max(floor, 0),
+                         StadiumMon.HOVER_CAP * math.max(model.height, 0))
+  local lift = (floor - hover) / root
+  local yaw = 0
+  if faceX and faceZ and (faceX ~= 0 or faceZ ~= 0) then
+    -- the card and the model share this convention: an unrotated model
+    -- faces +Z, which is map SOUTH, which is what "facing down" is in the
+    -- flat game (see Voxel3D's axis note)
+    yaw = math.atan2(faceX, faceZ)
+  end
+  self.yaw = yaw
+  return Mat4.mul(
+    Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
+             Mat4.scale(k, k, k)),
+    Mat4.translate(0, -lift, 0))
+end
+
+-- Pose and skin for this frame. Separate from the draw because both the
+-- SUN and the camera -- and, in a headset, both eyes -- want the same
+-- skinned mesh, and skinning it once is the whole reason this is worth
+-- doing on the CPU.
+function StadiumMon:build()
+  if not (self.rig and self.model) then return false end
+  -- self.anim is nil for a static species, and pose() reads that as "the
+  -- bind pose", which is exactly what is wanted
+  self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop)
+  self.rig:skin(self.yaw or 0)
+  -- no clock of its own: the texture animation rides the frame pose() just
+  -- resolved, which is what keeps a blink inside its standby loop and a
+  -- fainted Pokemon's eyes shut once it has stopped moving
+  self.rig:textures(self.aux)
+  return true
+end
+
+return StadiumMon
diff --git a/lib/StadiumPack.lua b/lib/StadiumPack.lua
new file mode 100644
index 0000000..3285fa9
--- /dev/null
+++ b/lib/StadiumPack.lua
@@ -0,0 +1,553 @@
+-- STADIUM battles: reading one species' model off disk.
+--
+-- `NNN.dsm` holds one Pokemon Stadium battle model. It is written by
+-- StadiumBuild, out of the player's own copy of that ROM, the first time the
+-- mod runs (see StadiumInstall) -- and by tools/stadium_pack.py, which is the
+-- oracle that Lua path is tested against. This file is the other half of that
+-- format and nothing else: bytes in, tables out. What the tables MEAN is
+-- StadiumRig's business (posing a skeleton) and StadiumMon's (which animation
+-- a fight is asking for).
+--
+-- Three things shape it.
+--
+-- BINARY, NOT LUA. A species is a couple of hundred kilobytes of numbers,
+-- most of it animation, and a Lua source file of that is a parse the loader
+-- would pay for on every boot whether a battle happened or not. A byte
+-- string is read once, on the frame a fight starts, and only for the two
+-- species actually fighting.
+--
+-- LAZY ANIMATIONS. Geometry, bones and textures are decoded on load --
+-- they are small, and every one of them is needed the moment the mon
+-- appears. The animations are not: a fight uses idle, an entrance and
+-- whichever handful of attacks come up, out of the seven to twenty-one a
+-- species carries. So the load pass SCANS the animation block, recording
+-- where each one starts and skipping the rest, and a track is decoded the
+-- first time something plays it. That turns a 200 KB decode into a 20 KB
+-- one plus a few milliseconds spread over the fight.
+--
+-- AN LRU OF FOUR. A model is shared by everything that draws that species
+-- -- both sides of a mirror match, both VR eyes -- and kept for a few
+-- battles after, because the next fight on the same route is very often
+-- the same Pokemon. Four is enough for a wild fight (two) plus the
+-- trainer's next two, and it bounds what the mode can hold to a few
+-- megabytes.
+--
+-- Everything is pcall-guarded and every failure answers nil: a missing
+-- pack, a truncated file or a driver that will not make an image all end
+-- at the same place, which is the flat 2D-3D card this mode falls back to
+-- (see Stadium).
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local StadiumPack = {}
+
+local byte = string.byte
+local floor = math.floor
+
+-- ------- where a pack comes from
+--
+-- Two places, asked in this order.
+--
+-- CACHE_DIR is in the save directory and is what actually ships: the mod
+-- carries no models (they are Pokemon Stadium's data), so StadiumInstall
+-- builds them out of the player's own ROM on first run and writes them here.
+--
+-- DIR is inside the mod, and exists for a developer checkout that has run
+-- tools/stadium_pack.py -- which is also how the oracle the Lua extractor is
+-- tested against gets built. It is second because a locally built cache
+-- should win over whatever a checkout happens to have lying around.
+StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
+StadiumPack.DIR = "assets/stadium"
+
+local function readPack(species)
+  local rel = ("%s/%03d.dsm"):format(StadiumPack.CACHE_DIR, species)
+  if love and love.filesystem and love.filesystem.getInfo then
+    local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
+    if okInfo and info then
+      local ok, bytes = pcall(love.filesystem.read, rel)
+      if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
+    end
+  end
+  local mod = V.mod
+  if not (mod and mod.read) then return nil end
+  local ok, bytes = pcall(mod.read, mod,
+                          ("%s/%03d.dsm"):format(StadiumPack.DIR, species))
+  if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
+  return nil
+end
+
+-- The battle system's context slots, in the order tools/stadium_pack.py
+-- writes them -- slot 165 upward (see model_extract/manifest.json's
+-- animationSlots). Indexed by POSITION, so this list is the format's
+-- contract and the packer's CONTEXTS must stay identical to it.
+StadiumPack.CONTEXT = {
+  "idle", "hit", "faint", "entrance", "reaction_169", "reaction_170",
+  "reaction_171", "reaction_172", "reaction_173", "reaction_174",
+  "struggle", "idle_alt", "faint_alt", "flinch", "reaction_179",
+  "reaction_180", "reaction_181", "reaction_182", "entrance_alt",
+  "idle_return",
+}
+
+-- name -> slot position, for callers that ask by name
+StadiumPack.SLOT = {}
+for i, name in ipairs(StadiumPack.CONTEXT) do StadiumPack.SLOT[name] = i end
+
+StadiumPack.N_MOVES = 165
+StadiumPack.NONE = 0xFFFF
+
+-- The frame rate every animation in the set is authored at
+-- (model_extract/README.md: keyframe times are frame / 30).
+StadiumPack.FPS = 30
+
+-- ------- readers
+--
+-- One cursor threaded through by hand rather than an object: this runs over
+-- a couple of hundred thousand values on the frame a battle starts, and a
+-- method call per value is the difference between a hitch and no hitch.
+
+local function u8(s, p) return byte(s, p), p + 1 end
+
+local function u16(s, p)
+  local a, b = byte(s, p, p + 1)
+  return a + b * 256, p + 2
+end
+
+local function i16(s, p)
+  local a, b = byte(s, p, p + 1)
+  local v = a + b * 256
+  if v >= 32768 then v = v - 65536 end
+  return v, p + 2
+end
+
+local function u32(s, p)
+  local a, b, c, d = byte(s, p, p + 3)
+  return a + b * 256 + c * 65536 + d * 16777216, p + 4
+end
+
+local function i32(s, p)
+  local v
+  v, p = u32(s, p)
+  if v >= 2147483648 then v = v - 4294967296 end
+  return v, p
+end
+
+-- IEEE 754 single, by hand. LOVE has love.data.unpack, but this file reads
+-- exactly four floats per model (the header's extents) and a hand decode
+-- costs nothing while removing a version floor from the mod's whole
+-- STADIUM path.
+local function f32(s, p)
+  local b1, b2, b3, b4 = byte(s, p, p + 3)
+  local sign = 1
+  if b4 >= 128 then sign, b4 = -1, b4 - 128 end
+  local expo = b4 * 2 + floor(b3 / 128)
+  local mant = (b3 % 128) * 65536 + b2 * 256 + b1
+  if expo == 255 then
+    if mant == 0 then return sign * math.huge, p + 4 end
+    return 0, p + 4
+  end
+  if expo == 0 then return sign * mant * 2 ^ -149, p + 4 end
+  return sign * (1 + mant / 8388608) * 2 ^ (expo - 127), p + 4
+end
+
+-- 16.16 fixed point, which is how bone scales are stored (they run from
+-- about -31 to 100 across the set and a float would cost twice the bytes
+-- for precision nothing can see).
+local function fixed(s, p)
+  local v
+  v, p = i32(s, p)
+  return v / 65536, p
+end
+
+-- ------- the load
+
+local function readHeader(s, p, model)
+  model.species, p = u16(s, p)
+  model.boneCount, p = u16(s, p)
+  model.primCount, p = u16(s, p)
+  model.texCount, p = u16(s, p)
+  model.animCount, p = u16(s, p)
+  model.auxCount, p = u16(s, p)
+  model.rootScale, p = f32(s, p)
+  -- a species whose standby loop is corrupt in the source extraction, and
+  -- which the mod therefore holds at its bind pose (see the packer's
+  -- idle_is_broken). Three of the 151.
+  local static
+  static, p = u8(s, p)
+  model.staticPose = static ~= 0
+  model.height, p = f32(s, p)
+  model.floor, p = f32(s, p)
+  model.radius, p = f32(s, p)
+
+  local moveAnim, moveAux, ctx = {}, {}, {}
+  for i = 1, StadiumPack.N_MOVES do moveAnim[i], p = u16(s, p) end
+  for i = 1, StadiumPack.N_MOVES do moveAux[i], p = i16(s, p) end
+  for i = 1, #StadiumPack.CONTEXT do ctx[i], p = u16(s, p) end
+  model.moveAnim, model.moveAux, model.ctx = moveAnim, moveAux, ctx
+  return p
+end
+
+-- The bone tree, as flat parallel arrays: a rig walk touches every bone
+-- every frame and an array of little tables would be a cache miss per bone
+-- and a table per bone to collect.
+local function readBones(s, p, model)
+  local n = model.boneCount
+  local parent, t, r, sc = {}, {}, {}, {}
+  for i = 1, n do
+    -- 0-based in the file, 1-based here, and 0 for "no parent" so the rig's
+    -- walk can test it without a sentinel comparison
+    local par
+    par, p = i16(s, p)
+    parent[i] = par + 1
+    local b = (i - 1) * 3
+    t[b + 1], p = i16(s, p)
+    t[b + 2], p = i16(s, p)
+    t[b + 3], p = i16(s, p)
+    r[b + 1], p = i16(s, p)
+    r[b + 2], p = i16(s, p)
+    r[b + 3], p = i16(s, p)
+    sc[b + 1], p = fixed(s, p)
+    sc[b + 2], p = fixed(s, p)
+    sc[b + 3], p = fixed(s, p)
+  end
+  model.parent, model.restT, model.restR, model.restS = parent, t, r, sc
+  return p
+end
+
+-- One drawable piece: the triangles that share a texture and a cull mode.
+--
+-- Positions and normals stay in BONE-LOCAL space, exactly as the display
+-- list had them, because that is what makes the skinning a single matrix
+-- multiply per vertex (every vertex in the set is rigidly bound to one bone
+-- -- see model_extract/README.md) rather than a weighted blend.
+local function readPrims(s, p, model)
+  local prims = {}
+  for i = 1, model.primCount do
+    local prim = {}
+    prim.tex, p = u16(s, p)
+    prim.tex = prim.tex + 1
+    local cull, blend
+    cull, p = u8(s, p)
+    blend, p = u8(s, p)
+    prim.cull = cull ~= 0
+    prim.additive = blend ~= 0
+    prim.texAnim, p = i16(s, p)
+
+    -- the texture-animation channel's value -> which texture to swap in.
+    -- Keyed by the stream's own byte, so the rig can look one up without
+    -- searching.
+    local mapN
+    mapN, p = u8(s, p)
+    if mapN > 0 then
+      local map = {}
+      for _ = 1, mapN do
+        local key, tex
+        key, p = u8(s, p)
+        tex, p = u16(s, p)
+        map[key] = tex + 1
+      end
+      prim.texMap = map
+    end
+
+    local fxN
+    fxN, p = u16(s, p)
+    if fxN > 0 then
+      local frames = {}
+      for k = 1, fxN do
+        frames[k], p = u16(s, p)
+        frames[k] = frames[k] + 1
+      end
+      prim.fxFrames = frames
+    end
+
+    local nv, ni
+    nv, p = u16(s, p)
+    ni, p = u16(s, p)
+    prim.vertCount, prim.indexCount = nv, ni
+
+    -- five arrays rather than one array of vertices, for the same reason
+    -- the bones are flat: the skinning loop reads them in step and writes
+    -- one LOVE vertex row out
+    local px, py, pz = {}, {}, {}
+    local uv = {}
+    local nx, ny, nz = {}, {}, {}
+    local bone = {}
+    for k = 1, nv do
+      px[k], p = i16(s, p)
+      py[k], p = i16(s, p)
+      pz[k], p = i16(s, p)
+      local u, v
+      u, p = i16(s, p)
+      v, p = i16(s, p)
+      uv[k * 2 - 1], uv[k * 2] = u / 512, v / 512
+      local a, b, c
+      a, p = u8(s, p)
+      b, p = u8(s, p)
+      c, p = u8(s, p)
+      if a >= 128 then a = a - 256 end
+      if b >= 128 then b = b - 256 end
+      if c >= 128 then c = c - 256 end
+      nx[k], ny[k], nz[k] = a / 127, b / 127, c / 127
+      bone[k], p = u8(s, p)
+      bone[k] = bone[k] + 1
+    end
+    prim.px, prim.py, prim.pz = px, py, pz
+    prim.uv = uv
+    prim.nx, prim.ny, prim.nz = nx, ny, nz
+    prim.bone = bone
+
+    local idx = {}
+    for k = 1, ni do
+      idx[k], p = u16(s, p)
+      idx[k] = idx[k] + 1
+    end
+    prim.index = idx
+    prims[i] = prim
+  end
+  model.prims = prims
+  return p
+end
+
+-- The textures, kept as the raw RGBA8 they arrived as and turned into
+-- images on first use. A species carries every frame of every blink and
+-- every dizzy swirl; a fight that never shows one should not pay to
+-- upload it.
+--
+-- Raw rather than PNG, which is what DSM3 changed: an ImageData over these
+-- bytes is a memcpy where a PNG is a decode on the frame a battle starts,
+-- and -- the reason it was actually done -- uncompressed pixels are the same
+-- pixels whichever side wrote them, so the Lua extractor's output can be
+-- diffed against the Python packer's byte for byte. Two deflate
+-- implementations need not agree; two arrays of pixels do.
+local function readTextures(s, p, model)
+  local tex = {}
+  for i = 1, model.texCount do
+    local w, h, len
+    w, p = u16(s, p)
+    h, p = u16(s, p)
+    len, p = u32(s, p)
+    tex[i] = { w = w, h = h, rgba = s:sub(p, p + len - 1) }
+    p = p + len
+  end
+  model.textures = tex
+  return p
+end
+
+-- How many bytes one animation's track block occupies, without decoding
+-- any of it. This is the scan that makes lazy animations possible: nine
+-- components a bone, each either one value or one a frame, and the only
+-- thing that has to be READ is the byte that says which.
+local COMP_BYTES = { 2, 2, 2, 2, 2, 2, 4, 4, 4 }   -- t t t r r r s s s
+
+local function skipTracks(s, p, boneCount, frames)
+  for _ = 1, boneCount do
+    local present
+    present, p = u8(s, p)
+    if present ~= 0 then
+      for c = 1, 9 do
+        local kind
+        kind, p = u8(s, p)
+        p = p + COMP_BYTES[c] * (kind == 0 and 1 or frames)
+      end
+    end
+  end
+  return p
+end
+
+local function readAnims(s, p, model)
+  local anims = {}
+  for i = 1, model.animCount do
+    local len
+    len, p = u8(s, p)
+    local name = s:sub(p, p + len - 1)
+    p = p + len
+    local frames, loopStart, aux
+    frames, p = u16(s, p)
+    loopStart, p = u16(s, p)
+    aux, p = i16(s, p)
+    anims[i] = {
+      name = name, frames = frames, loopStart = loopStart,
+      aux = aux >= 0 and (aux + 1) or nil,
+      seconds = frames / StadiumPack.FPS,
+      offset = p,                    -- where its tracks start; decoded later
+    }
+    p = skipTracks(s, p, model.boneCount, frames)
+  end
+  model.anims = anims
+  return p
+end
+
+local function readAux(s, p, model)
+  local aux = {}
+  for i = 1, model.auxCount do
+    local frames, loopStart, chanN
+    frames, p = u16(s, p)
+    loopStart, p = u16(s, p)
+    chanN, p = u16(s, p)
+    local chans = {}
+    for c = 1, chanN do
+      local n
+      n, p = u16(s, p)
+      local stream = {}
+      for k = 1, n do stream[k], p = u16(s, p) end
+      chans[c] = stream
+    end
+    aux[i] = { frames = frames, loopStart = loopStart, channels = chans }
+  end
+  model.auxAnims = aux
+  return p
+end
+
+-- ------- a track block, decoded on demand
+--
+-- The shape a pose walk wants: `tracks[bone]` is either nil (this bone
+-- holds its rest transform for the whole animation) or nine entries, each
+-- either a number (constant) or an array of one value per frame.
+--
+-- That fold is the source data's own, not something imposed here: a bone
+-- that only rotates costs two bytes for each of its six other components,
+-- and across the 151 species it is most of the reason the whole set is 24
+-- megabytes rather than a hundred.
+function StadiumPack.tracks(model, index)
+  local anim = model.anims and model.anims[index]
+  if not anim then return nil end
+  if anim.tracks then return anim.tracks end
+  local s, p = model.bytes, anim.offset
+  if not (s and p) then return nil end
+  local frames = anim.frames
+  local out = {}
+  for b = 1, model.boneCount do
+    local present
+    present, p = u8(s, p)
+    if present ~= 0 then
+      local comps = {}
+      for c = 1, 9 do
+        local kind
+        kind, p = u8(s, p)
+        local read = (c >= 7) and fixed or i16
+        if kind == 0 then
+          comps[c], p = read(s, p)
+        else
+          local arr = {}
+          for k = 1, frames do arr[k], p = read(s, p) end
+          comps[c] = arr
+        end
+      end
+      out[b] = comps
+    end
+  end
+  anim.tracks = out
+  return out
+end
+
+-- One texture as a LOVE image, decoded on first ask.
+function StadiumPack.image(model, index)
+  local slot = model.textures and model.textures[index]
+  if not slot then return nil end
+  if slot.image ~= nil then return slot.image or nil end
+  local ok, img = pcall(function()
+    local data = love.image.newImageData(slot.w, slot.h, "rgba8", slot.rgba)
+    local image = love.graphics.newImage(data)
+    -- N64 art at N64 resolution: nearest keeps the texels the size the
+    -- artist drew them, exactly as every other texture in this mode
+    image:setFilter("nearest", "nearest")
+    return image
+  end)
+  slot.image = (ok and img) or false
+  return slot.image or nil
+end
+
+-- ------- the cache
+
+local cache = {}          -- species -> model
+local order = {}          -- species, least recently used first
+StadiumPack.KEEP = 4
+
+local function touch(species)
+  for i = #order, 1, -1 do
+    if order[i] == species then table.remove(order, i) end
+  end
+  order[#order + 1] = species
+  while #order > StadiumPack.KEEP do
+    local drop = table.remove(order, 1)
+    local model = cache[drop]
+    cache[drop] = nil
+    if model and model.textures then
+      for _, slot in ipairs(model.textures) do
+        if slot.image and slot.image.release then
+          pcall(slot.image.release, slot.image)
+        end
+      end
+    end
+  end
+end
+
+-- Whether a pack for this species is on disk at all. Cheap enough to ask
+-- before a battle commits to the mode, and the honest test: a mod
+-- installed without its assets folder must decline rather than error.
+function StadiumPack.available(species)
+  if cache[species] then return true end
+  return readPack(species) ~= nil
+end
+
+-- The model for a National Dex number (1..151), or nil.
+function StadiumPack.load(species)
+  if not (species and species >= 1 and species <= 151) then return nil end
+  local hit = cache[species]
+  if hit ~= nil then
+    touch(species)
+    return hit or nil
+  end
+
+  local bytes = readPack(species)
+  if not bytes then
+    cache[species] = false
+    return nil
+  end
+
+  local ok, model = pcall(function()
+    if bytes:sub(1, 4) ~= "DSM3" then
+      error("not a DSM3 pack -- delete it and let the mod rebuild it", 0)
+    end
+    local m = { bytes = bytes }
+    local p = 5
+    p = readHeader(bytes, p, m)
+    p = readBones(bytes, p, m)
+    p = readPrims(bytes, p, m)
+    p = readTextures(bytes, p, m)
+    p = readAnims(bytes, p, m)
+    readAux(bytes, p, m)
+    return m
+  end)
+  if not ok then
+    V.mod.log:warn("stadium: %03d.dsm did not read: %s -- that Pokemon "
+                   .. "falls back to its flat pic", species, tostring(model))
+    cache[species] = false
+    return nil
+  end
+
+  cache[species] = model
+  touch(species)
+  return model
+end
+
+-- Drop everything (hot reload, or a graphics context that went away).
+function StadiumPack.invalidate()
+  for _, model in pairs(cache) do
+    if model and model.textures then
+      for _, slot in ipairs(model.textures) do
+        if slot.image and slot.image.release then
+          pcall(slot.image.release, slot.image)
+        end
+        slot.image = nil
+      end
+    end
+  end
+end
+
+function StadiumPack.forget()
+  StadiumPack.invalidate()
+  cache, order = {}, {}
+end
+
+return StadiumPack
diff --git a/lib/StadiumRig.lua b/lib/StadiumRig.lua
new file mode 100644
index 0000000..89c7220
--- /dev/null
+++ b/lib/StadiumRig.lua
@@ -0,0 +1,443 @@
+-- STADIUM battles: posing a skeleton and skinning it, on the CPU.
+--
+-- One instance of this is one Pokemon standing on the map -- the meshes it
+-- draws through and the scratch space its pose is computed in. The MODEL
+-- (geometry, bones, animations, textures) is shared and read-only; this is
+-- everything about it that is per-Pokemon and changes every frame.
+--
+-- ------- why the CPU
+--
+-- Because these models are tiny and the mod's shader already exists. A
+-- battle model is 674 vertices on average and 1311 at the worst, of which
+-- exactly two are on screen at a time -- so skinning them by hand costs
+-- about two thousand vertex transforms a frame, which is less than the
+-- grass pass does on an empty route. What it buys is that the finished
+-- vertices go into Voxel3D's OWN vertex format, through Voxel3D's OWN
+-- shader, and therefore get every single thing the rest of the diorama
+-- gets for free: the depth buffer decides what is in front of what, the
+-- sun pass throws a real shadow of the actual pose, the hour's tint lands
+-- on it, the hit flash flattens it, and the tilt-shift and the
+-- depth-of-field see it as part of the picture. A GPU skinning path would
+-- have needed a second shader that then had to re-implement all of that,
+-- and a second shadow shader beside it.
+--
+-- It is also what makes the FORMAT work. Every vertex in the Stadium set is
+-- rigidly bound to ONE bone with weight 1 (model_extract/README.md), so
+-- skinning is a single matrix multiply per vertex with no blend -- and the
+-- per-vertex `shade` Voxel3D wants, which no glTF has, is computed here
+-- from the bone-local normal.
+--
+-- ------- the two matrix chains
+--
+-- The game keeps bone scale OUT of the matrix chain (func_800143C0): scale
+-- accumulates in its own stack, a bone's local translation is
+-- pre-multiplied by its parent's accumulated scale, and a bone's own
+-- accumulated scale is applied to the finished matrix only at draw time.
+-- glTF cannot express that -- its node scale propagates to children -- and
+-- the reference export works around it by splitting every bone into two
+-- nodes.
+--
+-- Here it falls out naturally, as two arrays:
+--
+--   pivot   rotation and translation only. This is what a CHILD inherits,
+--           and it is a pure rotation, which is also why the normals are
+--           transformed with it rather than with the draw matrix.
+--   draw    the same matrix with the bone's accumulated scale applied on
+--           the right, which is the one vertices go through.
+--
+-- Folding the scale into the chain instead is the obvious mistake and it
+-- applies every ancestor's scale once per generation. It is caught by the
+-- suite: tools/stadium_pack.py measures the bind pose with this exact walk
+-- and its answer matches the verified glTF export on all 151 species.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Voxel3D = V.require("Voxel3D")
+local StadiumPack = V.require("StadiumPack")
+
+local StadiumRig = {}
+StadiumRig.__index = StadiumRig
+
+local sin, cos, floor = math.sin, math.cos, math.floor
+
+-- binary angle (32768 = pi) to radians
+local ANG = math.pi / 32768
+
+-- ------- how a surface is lit
+--
+-- Voxel3D shades a face by its DIRECTION rather than by a light uniform:
+-- every terrain and character mesh in this mode carries a per-vertex
+-- `shade` baked from which way its face points, and the shadow map
+-- multiplies on top of that (see Voxel3D.FACE_SHADE). A skinned model has
+-- no fixed faces to bake, so the same answer is computed per vertex from
+-- the posed normal -- and these four numbers are FACE_SHADE's own six
+-- values, fitted:
+--
+--     +Y up 1.00   -Y down 0.55   +X east 0.84   -X west 0.72
+--     +Z south 0.90   -Z north 0.68
+--
+-- so a Pokemon's flank catches the same southeastern sun the roof of the
+-- house behind it does, and the two read as being in one picture.
+local SHADE_BASE = 0.7725
+local SHADE_X = 0.06
+local SHADE_Y = 0.225
+local SHADE_Z = 0.11
+
+-- ------- an instance
+
+-- `model` is a StadiumPack model. Returns nil where meshes cannot be made,
+-- which is the same "no 3D" answer every other GPU object in this mod gives.
+function StadiumRig.new(model)
+  if not (model and model.prims) then return nil end
+  if not (love.graphics and love.graphics.newMesh) then return nil end
+
+  local self = setmetatable({
+    model = model,
+    -- The two chains, flat: twelve numbers a bone, row-major 3x4.
+    --
+    -- Named with the M rather than `pivot` and `draw` because an instance
+    -- field called `draw` shadows the DRAW METHOD through __index, and the
+    -- failure that causes is a nasty one: the shadow pass calls caster()
+    -- and keeps working, so a Pokemon casts a perfect animated shadow onto
+    -- ground it is not standing on.
+    pivotM = {},
+    drawM = {},
+    -- the accumulated scale, which is the third thing the game's own walk
+    -- carries and neither matrix can hold
+    accX = {}, accY = {}, accZ = {},
+    parts = {},
+    -- what the pose walk last answered, so a frame that neither moved the
+    -- animation nor turned the model can skip the whole thing
+    poseKey = nil,
+  }, StadiumRig)
+
+  -- One mesh per primitive: a primitive is already "the triangles sharing
+  -- one texture", which is exactly one draw call's worth.
+  --
+  -- "dynamic" rather than "static": every vertex is rewritten every frame
+  -- the pose changes, which is what the usage hint exists to say.
+  for i, prim in ipairs(model.prims) do
+    local rows = {}
+    local uv = prim.uv
+    for k = 1, prim.vertCount do
+      -- position and shade are filled by skin(); the texture coordinates
+      -- never change, so they are written once here
+      rows[k] = { 0, 0, 0, uv[k * 2 - 1], uv[k * 2], 1 }
+    end
+    local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.FORMAT, rows,
+                           "triangles", "dynamic")
+    if not ok then return nil end
+    pcall(mesh.setVertexMap, mesh, prim.index)
+    self.parts[i] = { mesh = mesh, rows = rows, prim = prim }
+  end
+  return self
+end
+
+function StadiumRig:release()
+  for _, part in ipairs(self.parts or {}) do
+    if part.mesh and part.mesh.release then
+      pcall(part.mesh.release, part.mesh)
+    end
+  end
+  self.parts = {}
+end
+
+-- ------- sampling one track
+--
+-- `c` is the pack's own fold: a bare number when the component holds still
+-- for the whole animation, or one value a frame when it does not. Two frame
+-- indices and a blend come in because the caller has already resolved what
+-- "between frame 12 and 13, three tenths of the way" means for THIS
+-- animation's looping.
+
+local function sampleLinear(c, i0, i1, k)
+  if type(c) == "number" then return c end
+  local a = c[i0]
+  if k <= 0 then return a end
+  local b = c[i1]
+  if b == nil then return a end
+  return a + (b - a) * k
+end
+
+-- The same, for a BINARY ANGLE, which wraps. Interpolating 32700 toward
+-- -32700 the long way round spins the bone most of a full turn in one
+-- frame; the short way is the one the eye expects and the one the game's
+-- own player takes.
+local function sampleAngle(c, i0, i1, k)
+  if type(c) == "number" then return c end
+  local a = c[i0]
+  if k <= 0 then return a end
+  local b = c[i1]
+  if b == nil then return a end
+  local d = b - a
+  if d > 32768 then d = d - 65536 elseif d < -32768 then d = d + 65536 end
+  return a + d * k
+end
+
+-- ------- the pose
+--
+-- `anim` is an index into model.anims (or nil for the bind pose), `frame` a
+-- FLOAT frame in that animation's own 30 Hz timeline, and `wrap` whether
+-- the far end joins back to loopStart (a standby loop) or holds on the last
+-- frame (a faint).
+function StadiumRig:pose(anim, frame, wrap)
+  local model = self.model
+  local n = model.boneCount
+  local tracks = anim and StadiumPack.tracks(model, anim) or nil
+  local frames = anim and model.anims[anim] and model.anims[anim].frames or 1
+
+  -- the two frames this instant falls between, and how far
+  local i0, i1, k = 1, 1, 0
+  if tracks and frames > 1 then
+    local f = frame
+    if f < 0 then f = 0 end
+    local base = floor(f)
+    k = f - base
+    if base >= frames - 1 then
+      if wrap then
+        local loop = model.anims[anim].loopStart or 0
+        base = base % frames
+        i0 = base + 1
+        i1 = (base + 1 >= frames) and (loop + 1) or (base + 2)
+      else
+        i0, i1, k = frames, frames, 0
+      end
+    else
+      i0, i1 = base + 1, base + 2
+    end
+    if i0 > frames then i0 = frames end
+    if i1 > frames then i1 = frames end
+  end
+
+  -- The frame this animation is actually SHOWING, after the wrap or the
+  -- hold, 0-based. Stashed rather than recomputed because the texture
+  -- animation is sampled at the very same frame (see textures) -- in the
+  -- game one counter drives both, and 73% of the paired animations in the
+  -- set are the same length as each other, which is what that looks like
+  -- from the outside. Two copies of this arithmetic would be two things to
+  -- keep in step; one number cannot drift from itself.
+  self.frameAt = i0 - 1
+
+  local parent = model.parent
+  local restT, restR, restS = model.restT, model.restR, model.restS
+  local pivot, drw = self.pivotM, self.drawM
+  local accX, accY, accZ = self.accX, self.accY, self.accZ
+
+  for b = 1, n do
+    local o3 = (b - 1) * 3
+    local tx, ty, tz, rx, ry, rz, kx, ky, kz
+    local comps = tracks and tracks[b]
+    if comps then
+      tx = sampleLinear(comps[1], i0, i1, k)
+      ty = sampleLinear(comps[2], i0, i1, k)
+      tz = sampleLinear(comps[3], i0, i1, k)
+      rx = sampleAngle(comps[4], i0, i1, k)
+      ry = sampleAngle(comps[5], i0, i1, k)
+      rz = sampleAngle(comps[6], i0, i1, k)
+      kx = sampleLinear(comps[7], i0, i1, k)
+      ky = sampleLinear(comps[8], i0, i1, k)
+      kz = sampleLinear(comps[9], i0, i1, k)
+    else
+      -- a bone this animation never touches keeps its rest transform
+      tx, ty, tz = restT[o3 + 1], restT[o3 + 2], restT[o3 + 3]
+      rx, ry, rz = restR[o3 + 1], restR[o3 + 2], restR[o3 + 3]
+      kx, ky, kz = restS[o3 + 1], restS[o3 + 2], restS[o3 + 3]
+    end
+
+    local p = parent[b]
+    local pax, pay, paz = 1, 1, 1
+    if p > 0 then pax, pay, paz = accX[p], accY[p], accZ[p] end
+    -- the parent's accumulated scale, applied to the CHILD's offset. This
+    -- is the whole of what the game does instead of propagating scale.
+    tx, ty, tz = tx * pax, ty * pay, tz * paz
+
+    -- Rx * Ry * Rz in the game's own row-vector form (src/F420.c
+    -- func_8000F730), written out as the rows of a 3x3
+    local ax, ay, az = rx * ANG, ry * ANG, rz * ANG
+    local sx, cx = sin(ax), cos(ax)
+    local sy, cy = sin(ay), cos(ay)
+    local sz, cz = sin(az), cos(az)
+    local m11, m12, m13 = cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz
+    local m21, m22, m23 = cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz
+    local m31, m32, m33 = -sy, sx * cy, cx * cy
+
+    local o = (b - 1) * 12
+    if p > 0 then
+      local q = (p - 1) * 12
+      local a1, a2, a3, a4 = pivot[q + 1], pivot[q + 2], pivot[q + 3], pivot[q + 4]
+      local b1, b2, b3, b4 = pivot[q + 5], pivot[q + 6], pivot[q + 7], pivot[q + 8]
+      local c1, c2, c3, c4 = pivot[q + 9], pivot[q + 10], pivot[q + 11], pivot[q + 12]
+      pivot[o + 1] = a1 * m11 + a2 * m21 + a3 * m31
+      pivot[o + 2] = a1 * m12 + a2 * m22 + a3 * m32
+      pivot[o + 3] = a1 * m13 + a2 * m23 + a3 * m33
+      pivot[o + 4] = a1 * tx + a2 * ty + a3 * tz + a4
+      pivot[o + 5] = b1 * m11 + b2 * m21 + b3 * m31
+      pivot[o + 6] = b1 * m12 + b2 * m22 + b3 * m32
+      pivot[o + 7] = b1 * m13 + b2 * m23 + b3 * m33
+      pivot[o + 8] = b1 * tx + b2 * ty + b3 * tz + b4
+      pivot[o + 9] = c1 * m11 + c2 * m21 + c3 * m31
+      pivot[o + 10] = c1 * m12 + c2 * m22 + c3 * m32
+      pivot[o + 11] = c1 * m13 + c2 * m23 + c3 * m33
+      pivot[o + 12] = c1 * tx + c2 * ty + c3 * tz + c4
+    else
+      pivot[o + 1], pivot[o + 2], pivot[o + 3], pivot[o + 4] = m11, m12, m13, tx
+      pivot[o + 5], pivot[o + 6], pivot[o + 7], pivot[o + 8] = m21, m22, m23, ty
+      pivot[o + 9], pivot[o + 10], pivot[o + 11], pivot[o + 12] = m31, m32, m33, tz
+    end
+
+    local ex, ey, ez = pax * kx, pay * ky, paz * kz
+    accX[b], accY[b], accZ[b] = ex, ey, ez
+    -- the bone's own accumulated scale, on the right: it scales the axes of
+    -- THIS bone's space and cannot reach the children, which is exactly the
+    -- game's draw-time application
+    drw[o + 1], drw[o + 2] = pivot[o + 1] * ex, pivot[o + 2] * ey
+    drw[o + 3], drw[o + 4] = pivot[o + 3] * ez, pivot[o + 4]
+    drw[o + 5], drw[o + 6] = pivot[o + 5] * ex, pivot[o + 6] * ey
+    drw[o + 7], drw[o + 8] = pivot[o + 7] * ez, pivot[o + 8]
+    drw[o + 9], drw[o + 10] = pivot[o + 9] * ex, pivot[o + 10] * ey
+    drw[o + 11], drw[o + 12] = pivot[o + 11] * ez, pivot[o + 12]
+  end
+end
+
+-- ------- the skin
+--
+-- Every vertex through its one bone's draw matrix, and its normal through
+-- the same bone's pivot (a pure rotation, so the normal survives a
+-- non-uniformly scaled bone -- which several species have).
+--
+-- `yaw` is the model matrix's own turn, and it is folded in HERE rather
+-- than left to the matrix because the shade has to be computed against the
+-- WORLD normal: a Pokemon turned to face its opponent has a differently lit
+-- flank than one facing the camera, and the sun does not turn with it.
+function StadiumRig:skin(yaw)
+  local cy, sy = cos(yaw or 0), sin(yaw or 0)
+  local drw, piv = self.drawM, self.pivotM
+  for _, part in ipairs(self.parts) do
+    local prim, rows = part.prim, part.rows
+    local px, py, pz = prim.px, prim.py, prim.pz
+    local nx, ny, nz = prim.nx, prim.ny, prim.nz
+    local bone = prim.bone
+    for k = 1, prim.vertCount do
+      local o = (bone[k] - 1) * 12
+      local x, y, z = px[k], py[k], pz[k]
+      local row = rows[k]
+      row[1] = drw[o + 1] * x + drw[o + 2] * y + drw[o + 3] * z + drw[o + 4]
+      row[2] = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z + drw[o + 8]
+      row[3] = drw[o + 9] * x + drw[o + 10] * y + drw[o + 11] * z + drw[o + 12]
+      local ax, ay, az = nx[k], ny[k], nz[k]
+      local wx = piv[o + 1] * ax + piv[o + 2] * ay + piv[o + 3] * az
+      local wy = piv[o + 5] * ax + piv[o + 6] * ay + piv[o + 7] * az
+      local wz = piv[o + 9] * ax + piv[o + 10] * ay + piv[o + 11] * az
+      -- the model matrix's yaw, by hand: (x, z) turned, y untouched
+      row[6] = SHADE_BASE + SHADE_X * (cy * wx + sy * wz) + SHADE_Y * wy
+               + SHADE_Z * (cy * wz - sy * wx)
+    end
+    pcall(part.mesh.setVertices, part.mesh, rows)
+  end
+end
+
+-- ------- which texture each part wears this frame
+--
+-- The eyes. A primitive whose display list carried geo command 0x23 with a
+-- channel index has its texture REPLACED every frame from a stream of
+-- texture-table indices (src/18140.c func_800176DC) -- which is how every
+-- Pokemon in the game blinks, and how a confused one gets swirls. glTF has
+-- no channel for that, so the .glb files carry only the first frame; the
+-- pack carries the streams.
+--
+-- `aux` is an index into model.auxAnims (the stream set) and `frame` its
+-- own frame counter, which runs independently of the skeletal one.
+-- The eyes, and everything else a material swaps per frame.
+--
+-- Sampled at the SKELETAL animation's own frame -- the one pose() just
+-- resolved -- and CLAMPED past the end of the stream rather than wrapped.
+-- Both halves of that matter, and getting either wrong is visible.
+--
+-- The frame is the skeleton's because in the game a single counter drives
+-- both; the data says so plainly, since 507 of the 691 paired animations in
+-- the set have a texture animation exactly as long as the skeletal one it
+-- rides with.
+--
+-- The clamp is what the game's own sampler does (func_80017540 indexes the
+-- stream and holds the last entry past its end), and it is the whole
+-- difference between a blink and a twitch. Rattata's standby loop is forty
+-- frames and its blink is FIVE -- `6 8 7 8 6`, open through closed and back.
+-- Wrapped on the blink's own length that plays six times a second, which is
+-- what it looked like. Clamped, the eye blinks once at the top of the loop
+-- and stays open for the remaining thirty-five frames, so it blinks about
+-- once a second and a half.
+function StadiumRig:textures(aux)
+  local model = self.model
+  local anim = aux and model.auxAnims and model.auxAnims[aux] or nil
+  local frame = self.frameAt or 0
+  for _, part in ipairs(self.parts) do
+    local prim = part.prim
+    local index = prim.tex
+    if anim and prim.texAnim and prim.texAnim >= 0 and prim.texMap then
+      local stream = anim.channels[prim.texAnim + 1]
+      local n = stream and #stream or 0
+      if n > 0 then
+        local at = frame + 1
+        if at > n then at = n end
+        if at < 1 then at = 1 end
+        local mapped = prim.texMap[stream[at]]
+        if mapped then index = mapped end
+      end
+    end
+    part.texture = StadiumPack.image(model, index)
+  end
+end
+
+-- ------- the draw
+--
+-- `model` here is the MODEL MATRIX -- where this Pokemon stands, how big
+-- and which way round -- and `sunModel` the transform the shadow pass drew
+-- it with, which for these is the same matrix (unlike a character's leaning
+-- card; see Voxel3D.draw).
+--
+-- Seams off for the whole of it: the voxel wireframe draws the integer
+-- planes of a mesh's own model space, and these vertices are in the N64's
+-- own units where an integer plane means nothing (see VoxelGrid). Glass off
+-- for the same reason the sprite passes turn it off -- the mask's
+-- coordinates belong to the tileset atlas, not to a Pokemon's texture.
+function StadiumRig:draw(matrix, pull)
+  Voxel3D.seams(false)
+  Voxel3D.glass(false)
+  local additive = nil
+  for _, part in ipairs(self.parts) do
+    if part.prim.additive then
+      -- held back to a second pass so the flames composite over the body
+      -- rather than depth-fighting it
+      additive = additive or {}
+      additive[#additive + 1] = part
+    elseif part.texture then
+      Voxel3D.draw(part.mesh, part.texture, matrix, pull)
+    end
+  end
+  if additive then
+    Voxel3D.blend("add")
+    for _, part in ipairs(additive) do
+      if part.texture then
+        Voxel3D.draw(part.mesh, part.texture, matrix, pull)
+      end
+    end
+    Voxel3D.blend(nil)
+  end
+  Voxel3D.glass(true)
+  Voxel3D.seams(true)
+end
+
+-- The same geometry as the SUN sees it: no camera-ward pull (a trick for
+-- the view's own depth buffer, which would drag a shadow off its owner) and
+-- through the shadow pass's own draw call. The generated flame prims are
+-- skipped -- a fire casts light, not a shadow.
+function StadiumRig:caster(shadowMap, matrix)
+  for _, part in ipairs(self.parts) do
+    if part.texture and not part.prim.additive then
+      shadowMap.draw(part.mesh, part.texture, matrix)
+    end
+  end
+end
+
+return StadiumRig
diff --git a/lib/StadiumRom.lua b/lib/StadiumRom.lua
new file mode 100644
index 0000000..e57493f
--- /dev/null
+++ b/lib/StadiumRom.lua
@@ -0,0 +1,314 @@
+-- STADIUM battles: getting at the Pokemon Stadium ROM.
+--
+-- Byte order, the archive the battle models are packed into, the Yay0
+-- decompressor that unwraps each one, and the per-species battle tables. It
+-- is a port of model_extract/pipeline/rom.py, function for function, and the
+-- Python remains the reference: tools/stadium_pack.py drives that side and
+-- tests/stadium_extract_test.lua diffs this side's finished packs against it
+-- byte for byte.
+--
+-- ------- why this exists in Lua at all
+--
+-- The mod cannot ship the models. They are ROM data, so what ships is the
+-- READER, and the player supplies the ROM -- exactly the arrangement the
+-- engine itself already has for the Game Boy ROM it is a recompilation of
+-- (src/import/RomImporter.lua). Everything from `baserom.z64` to
+-- `assets/stadium/NNN.dsm` therefore has to happen here, on the machine, in
+-- Lua, with no Python and no build step.
+--
+-- ------- what makes that tractable
+--
+-- Three steps, and none of them needs a decompilation toolchain:
+--
+--   1. BYTE ORDER. The three N64 dump conventions differ by a swap that is
+--      detected from the magic word and undone once, on load.
+--   2. THE ARCHIVE. The segment at 0x920000 is a count and a table of
+--      (offset, size) records. No compression at that level, no names.
+--   3. Yay0. Nintendo's LZ variant: a bitstream where a 1 copies a literal
+--      byte and a 0 pulls a (distance, length) pair out of a side table