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