dramatic stadium

This commit is contained in:
DramaticShape
2026-08-04 10:43:35 -04:00
parent 7b1ac9b1b6
commit 74cc08f1bf
38 changed files with 9419 additions and 50 deletions
+39
View File
@@ -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/
+191
View File
@@ -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
+61
View File
@@ -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
+86 -16
View File
@@ -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
+79 -4
View File
@@ -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 }
+123 -12
View File
@@ -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
+516
View File
@@ -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
+709
View File
@@ -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
File diff suppressed because it is too large Load Diff
+436
View File
@@ -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
+261
View File
@@ -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
+350
View File
@@ -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
+553
View File
@@ -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
+443
View File
@@ -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
+314
View File
@@ -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.
-- Thirty lines, and the same thirty lines the Python has.
--
-- Verified in the Python by decompressing all 215 entries and diffing against
-- what the decompilation's own `make init` produces: 215/215 identical.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumRom = {}
local byte = string.byte
local char = string.char
local concat = table.concat
local sub = string.sub
local floor = math.floor
-- ROM offsets, from pokestadium-us.yaml by way of pipeline/rom.py.
StadiumRom.POKEMON_MODELS = 0x920000 -- archive of the 215 battle models
StadiumRom.BATTLE_DATA = 0x70D3A0 -- per-species battle tables
StadiumRom.MAIN_ROM = 0x1000 -- main code segment ...
StadiumRom.MAIN_VRAM = 0x80000400 -- ... and where it lands in RAM
StadiumRom.PTR_TABLE_VRAM = 0x80075BD0 -- D_80075BD0[species - 1]
-- The revision every offset above is keyed to. A different ROM still runs --
-- it may well be a regional variant with the same layout -- but the caller is
-- told, because "the models came out as garbage" and "that is not the ROM
-- this was written against" are the same fact and only one of them is useful.
StadiumRom.US_MD5 = "ed1378bc12115f71209a77844965ba50"
-- The battle table's shape: 0xB90 bytes a species, as 0x10-byte entries.
-- Entries 0..164 are the moves (entry n drives move n + 1) and 165 up are the
-- fixed battle contexts.
StadiumRom.STRIDE = 0xB90
StadiumRom.ENTRY = 0x10
StadiumRom.N_MOVES = 165
-- How many of the archive's 215 models are the battle Pokemon. The rest are
-- props and trophies with no battle table.
StadiumRom.N_POKEMON = 151
-- ------- byte order
--
-- .z64 is big-endian and native; .v64 has each pair of bytes swapped; .n64
-- has each word reversed. `gsub` with a capture-reversing replacement does
-- either in one call through C rather than a Lua loop over 33 million bytes.
local MAGIC_Z64 = "\128\055\018\064"
local MAGIC_V64 = "\055\128\064\018"
local MAGIC_N64 = "\064\018\055\128"
-- Normalise a dump to .z64 order, or nil when it is not an N64 ROM at all.
function StadiumRom.normalise(bytes)
if type(bytes) ~= "string" or #bytes < 0x1000 then return nil end
local magic = sub(bytes, 1, 4)
if magic == MAGIC_Z64 then return bytes end
if magic == MAGIC_V64 then return (bytes:gsub("(.)(.)", "%2%1")) end
if magic == MAGIC_N64 then
return (bytes:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
end
return nil
end
-- ------- Yay0
--
-- The output has to be RANDOM ACCESS while it is being written -- a back
-- reference copies from what has already been produced, and overlapping runs
-- are legal and common -- so it is built in a flat table of byte values and
-- turned into a string at the end.
--
-- The string.char conversion is the part that wants care: it is variadic and
-- has an argument limit, so the table is walked in blocks and the blocks
-- concatenated. Blocks of 4096 keep the call count and the intermediate
-- string count both low; the whole 151-model set converts in well under a
-- second on LuaJIT, which is what made an FFI buffer unnecessary here and
-- kept this module portable to any Lua the engine runs on.
local CHUNK = 4096
-- LuaJIT keeps `unpack` global; 5.2+ moved it onto table.
local unpack = unpack or table.unpack
local function bytesToString(out, n)
if n == 0 then return "" end
local parts, np = {}, 0
local i = 1
while i <= n do
local j = i + CHUNK - 1
if j > n then j = n end
np = np + 1
parts[np] = char(unpack(out, i, j))
i = j + 1
end
return concat(parts)
end
-- Nintendo Yay0. Header: magic, decompressed size, link table offset, chunk
-- offset; then a bitstream read a word at a time.
function StadiumRom.yay0(src, base)
base = base or 0
if sub(src, base + 1, base + 4) ~= "Yay0" then return nil, "not Yay0" end
local function be32(o)
local a, b, c, d = byte(src, base + o + 1, base + o + 4)
return ((a * 256 + b) * 256 + c) * 256 + d
end
local size = be32(4)
-- all three cursors are 1-based indices into `src`; the mask stream starts
-- immediately after the 16-byte header
local maskP = base + 0x10 + 1
local linkP = base + be32(8) + 1
local chunkP = base + be32(12) + 1
local out = {}
local pos = 0 -- bytes produced so far
local mask, bits = 0, 0
while pos < size do
if bits == 0 then
local a, b, c, d = byte(src, maskP, maskP + 3)
mask = ((a * 256 + b) * 256 + c) * 256 + d
maskP = maskP + 4
bits = 32
end
if mask >= 0x80000000 then
pos = pos + 1
out[pos] = byte(src, chunkP)
chunkP = chunkP + 1
else
local a, b = byte(src, linkP, linkP + 1)
linkP = linkP + 2
local link = a * 256 + b
local dist = link % 0x1000
local count = floor(link / 0x1000)
if count == 0 then
count = byte(src, chunkP) + 0x12
chunkP = chunkP + 1
else
count = count + 2
end
-- overlapping runs are legal: copying one byte at a time from the
-- output as it grows is the behaviour, not a naive version of it
local copy = pos - dist
for _ = 1, count do
pos = pos + 1
out[pos] = out[copy]
copy = copy + 1
end
end
mask = (mask * 2) % 0x100000000
bits = bits - 1
end
return bytesToString(out, size)
end
-- Unwrap whatever container an asset arrived in. The model archive's entries
-- are PERS-SZP: an eight-byte magic plus a header size, wrapping a Yay0
-- stream.
function StadiumRom.decompress(blob)
if sub(blob, 1, 8) == "PERS-SZP" then
local a, b, c, d = byte(blob, 9, 12)
local header = ((a * 256 + b) * 256 + c) * 256 + d
return StadiumRom.yay0(blob, header)
end
if sub(blob, 1, 4) == "Yay0" then return StadiumRom.yay0(blob, 0) end
return blob
end
-- ------- the ROM
local Rom = {}
Rom.__index = Rom
-- `bytes` is the whole file. Returns the ROM, or nil plus why.
function StadiumRom.open(bytes)
local data = StadiumRom.normalise(bytes)
if not data then return nil, "not an N64 ROM (bad magic)" end
return setmetatable({ data = data }, Rom)
end
function Rom:u8(o)
return byte(self.data, o + 1)
end
function Rom:u32(o)
local a, b, c, d = byte(self.data, o + 1, o + 4)
if not d then return 0 end
return ((a * 256 + b) * 256 + c) * 256 + d
end
function Rom:vramToRom(vram)
return StadiumRom.MAIN_ROM + (vram - StadiumRom.MAIN_VRAM)
end
-- The md5 of the normalised image, or nil where LOVE's hash is not there
-- (the headless suite). Only ever used to tell the player which ROM they
-- gave us, never to refuse one.
function Rom:md5()
if self.hash ~= nil then return self.hash or nil end
local ok, hex = pcall(function()
local digest = love.data.hash("md5", self.data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest)
end)
self.hash = (ok and hex) or false
return self.hash or nil
end
function Rom:isExpectedUS()
local hex = self:md5()
return hex == nil or hex == StadiumRom.US_MD5
end
-- ------- the archive
--
-- Segments that hold many files start with
-- u32 tag, u32 0, u32 totalSize, u32 fileCount
-- followed by fileCount { u32 offset, u32 size, u32 pad[2] } records, all
-- relative to the start of the segment.
--
-- Only the top three bytes of the first word are reliably zero: the model
-- archive puts a nonzero value in the low byte, which is the same quirk the
-- decompilation's own tools/unpack_asset.py works around.
--
-- Returns a list of { start, size } rather than the bytes, so nothing is
-- copied until a caller actually wants a file.
function Rom:archive(off)
local tag = self:u32(off)
if (tag - tag % 256) ~= 0 or self:u32(off + 4) ~= 0 then return nil end
local count = self:u32(off + 12)
if count <= 0 or count >= 4096 then return nil end
local out = {}
for i = 0, count - 1 do
local rec = off + 0x10 + i * 0x10
out[i + 1] = { start = off + self:u32(rec), size = self:u32(rec + 4) }
end
return out
end
-- The entries of the battle-model archive, uncopied.
function Rom:models()
if not self.modelDir then
self.modelDir = self:archive(StadiumRom.POKEMON_MODELS) or {}
end
return self.modelDir
end
function Rom:modelCount()
return #self:models()
end
-- One model fragment, decompressed. `fileno` is 0-based, as in the Python and
-- in the source-file names: `N.bin` holds species N + 1.
function Rom:model(fileno)
local rec = self:models()[fileno + 1]
if not rec then return nil end
return StadiumRom.decompress(sub(self.data, rec.start + 1,
rec.start + rec.size))
end
-- ------- the per-species battle tables
--
-- func_84302658 in src/fragments/62 DMAs 0xB90 bytes a species out of the
-- 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. Byte 0 of
-- each 0x10-byte entry indexes that Pokemon's animation list and byte 1 its
-- auxiliary (texture) animation list.
--
-- Returns a 0-based array-like table of { anim, aux }, aux 0xFF meaning none
-- and coming back as -1 -- the shape the packer writes.
function Rom:battleRows(species)
local ptrTable = self:vramToRom(StadiumRom.PTR_TABLE_VRAM)
local raw = self:u32(ptrTable + (species - 1) * 4)
local o = StadiumRom.BATTLE_DATA + raw % 0x1000000
local rows = {}
local n = StadiumRom.STRIDE / StadiumRom.ENTRY
for e = 0, n - 1 do
local anim = self:u8(o + e * StadiumRom.ENTRY)
local aux = self:u8(o + e * StadiumRom.ENTRY + 1)
rows[e] = { anim, aux == 0xFF and -1 or aux }
end
rows.n = n
return rows
end
return StadiumRom
+218
View File
@@ -0,0 +1,218 @@
-- STADIUM battles: the one-time build, on screen.
--
-- A pushed game state, so it draws in the Game Boy's own 160x144 and stops
-- everything under it -- which is what it should do, because it is doing real
-- work and the player should not be walking around while it happens.
--
-- ------- why a species a frame
--
-- A species takes roughly fifty milliseconds to extract, and there are 151 of
-- them. That is ten seconds, which has to go somewhere. Doing them one per
-- frame puts the whole cost on this screen where it is explained, keeps the
-- bar moving at a visible rate, and leaves the frame free to draw between
-- them. Batching more per frame would finish no sooner -- the work is the
-- same -- and would only make the bar jump.
--
-- ------- what it says
--
-- Which Pokemon it is on, by name. That is not decoration: it is the
-- difference between a progress bar the player trusts and one they suspect
-- has hung, and it costs one lookup a frame.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumInstall = V.require("StadiumInstall")
local StadiumScreen = {}
StadiumScreen.__index = StadiumScreen
-- The Game Boy frame this draws in.
local W, H = 160, 144
-- How long the finished message stays up before the screen retires itself.
StadiumScreen.HOLD = 1.1
local Font = nil
local function font()
if Font then return Font end
local ok, F = pcall(require, "src.render.Font")
if ok then Font = F end
return Font
end
-- Black glyphs, because that is the only colour the Game Boy font sheets
-- have -- they are black on transparent, so setColor cannot lighten one.
-- Everything here is therefore laid out dark-on-light.
local function text(str, x, y)
local F = font()
if not F then return end
love.graphics.setColor(0, 0, 0, 1)
F.draw(str, math.floor(x), math.floor(y))
end
local function centred(str, y)
local F = font()
if not F then return end
text(str, (W - F.width(str)) / 2, y)
end
-- ------- dex number -> the engine's own species key
--
-- Built once, from the loaded data rather than from a list of names carried
-- here: a list would be a second place for the same 151 facts to live, and
-- would go stale against a mod that renames one.
local dexNames = nil
local function speciesName(dex)
if not dex then return nil end
if not dexNames then
dexNames = {}
local ok, data = pcall(function()
return require("src.core.Game").data
end)
if ok and data and data.pokemon then
for key, def in pairs(data.pokemon) do
if type(def) == "table" and def.dex then dexNames[def.dex] = key end
end
end
end
return dexNames[dex]
end
function StadiumScreen.new(game)
return setmetatable({ game = game, hold = 0, started = false }, StadiumScreen)
end
-- Opaque: the loading screen owns the frame, so the map underneath is not
-- drawn and not paying for a render it cannot be seen through.
StadiumScreen.isOpaque = true
function StadiumScreen:enter()
local ok, err = StadiumInstall.begin()
self.started = ok and true or false
if not ok then
StadiumInstall.status.state = "failed"
StadiumInstall.status.error = err
end
end
function StadiumScreen:update()
local status = StadiumInstall.status
if status.state == "building" then
if not StadiumInstall.step() then
-- fell out of building: either finished or failed, both of which hold
-- for a moment so the player sees which
self.hold = 0
end
return
end
self.hold = self.hold + 1 / 60
-- a failure stays up longer, because it is the one the player has to read
local wait = (status.state == "failed") and StadiumScreen.HOLD * 4
or StadiumScreen.HOLD
if self.hold >= wait then
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
end
end
-- Let the player out of a build that has gone wrong, or that they would
-- rather not wait for. Cancelling leaves the packs unbuilt, so the STADIUM
-- rungs stay off the row until the next boot offers again -- which is
-- honest, and better than a half-built set.
function StadiumScreen:onKeyPressed(key)
if key == "escape" or key == "x" or key == "backspace" then
StadiumInstall.cancel()
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
return true
end
return false
end
function StadiumScreen:draw()
local status = StadiumInstall.status
love.graphics.setColor(0.93, 0.94, 0.90, 1)
love.graphics.rectangle("fill", 0, 0, W, H)
centred("POKEMON STADIUM", 24)
centred("BATTLE MODELS", 34)
if status.state == "failed" then
centred("COULD NOT BUILD", 62)
local why = tostring(status.error or "unknown"):sub(1, 24)
centred(why, 76)
centred("STADIUM IS OFF", 96)
return
end
local done = status.done or 0
local total = status.total or StadiumInstall.COUNT
local frac = (total > 0) and (done / total) or 1
if status.state == "done" then frac = 1 end
-- the bar: a dark frame with a dark fill, so it reads on the light plate
local bx, by, bw, bh = 24, 66, W - 48, 9
love.graphics.setColor(0.06, 0.05, 0.09, 1)
love.graphics.rectangle("fill", bx - 1, by - 1, bw + 2, bh + 2)
love.graphics.setColor(0.80, 0.80, 0.78, 1)
love.graphics.rectangle("fill", bx, by, bw, bh)
love.graphics.setColor(0.06, 0.05, 0.09, 1)
love.graphics.rectangle("fill", bx, by, math.floor(bw * frac + 0.5), bh)
if status.state == "done" then
centred("READY", 84)
else
local name = speciesName(status.species)
centred(("%d/%d"):format(done, total), 84)
if name then centred(name, 96) end
centred("BUILDING FROM ROM", 112)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- ------- when this comes up
--
-- The first frame the player is actually IN the world, rather than at boot.
-- Two reasons. The engine has its own launcher and ROM importer before the
-- game starts, and pushing over those would be fighting them for the screen.
-- And `Game.data` has to be loaded for the species names above to resolve.
--
-- Asked once. If the player cancels, or there is no ROM, this does not come
-- back until the next run -- a loading screen that reappears every time you
-- step outside would be worse than no stadium models.
local asked = false
function StadiumScreen.maybePush()
if asked then return false end
local ok, Game = pcall(require, "src.core.Game")
if not (ok and Game and Game.stack and Game.overworld) then return false end
if Game.stack:top() ~= Game.overworld then return false end
asked = true
if not StadiumInstall.pending() then
-- Say where to put a cartridge, ONCE, and only when there is nothing to
-- build from and nothing already built. The two STADIUM rungs are simply
-- absent in that case (ModSetting.setGate), which is the right thing for
-- a row to do and tells the player nothing about why -- and the answer
-- they need is an absolute path that depends on how the game was
-- installed, so it cannot be written into the options help text.
if not StadiumInstall.available() then
V.mod.log:info("stadium: no Pokemon Stadium ROM found, so the STADIUM "
.. "battle rungs are off. Put one (.z64/.n64/.v64) in "
.. "%s and restart.", StadiumInstall.romHint())
end
return false
end
Game.stack:push(StadiumScreen.new(Game))
return true
end
-- named for the suite, which drives the screen without a boot
function StadiumScreen._reset()
asked = false
end
return StadiumScreen
+330
View File
@@ -0,0 +1,330 @@
-- STADIUM B: the two discs the fight is staged on.
--
-- The 3D-BTL row's fourth rung. Where STADIUM A puts the Pokemon Stadium
-- models on the MAP -- real ground, whatever the route happens to look like
-- -- this puts them on two platforms against the sky and draws no map at all.
--
-- ------- why this is a rung and not a fix
--
-- Staging on the map is the better picture when the map cooperates, and it
-- often does not. Half of Kanto's interiors are furniture; a cave floor can
-- be nothing but two-cell corridors; some maps have nowhere a fight can be
-- SEEN from a low camera and are declined outright (see BattleArena), which
-- drops the player back to the flat battle screen with no warning. And even
-- where a spot exists, the ground behind the foe is a hedge or a shop counter
-- rather than anything a battle wants behind it.
--
-- Discs have none of those problems, because the stage is CARRIED rather than
-- found: it works on every map, in every building, at every step, and the
-- framing is the same every time. What it gives up is the thing STADIUM A is
-- for -- fighting somewhere real.
--
-- ------- what stays
--
-- The sky, and the light. A battle outdoors is under the hour's own sky, with
-- its bands and its sun or moon (Voxel3D.beginScene paints it when handed a
-- dressed one); a battle in a cave or a room is under that place's own void
-- and its own neutral light, exactly as the map itself would be. So the mode
-- is abstracted from the GROUND, not from the world -- walk into a cave at
-- midnight and the fight looks like a cave at midnight.
--
-- And the framing. The camera, the pins, the HUDs, the text box, the move
-- animations and the depth of field are all identical, because every one of
-- them is hung off the arena's CELLS rather than off what is under them. That
-- is the same reason STADIUM A could be an option on the mode rather than a
-- second mode, and it is why this file is a few hundred lines and not a few
-- thousand.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local StadiumStage = {}
local floor = math.floor
local sin, cos = math.sin, math.cos
local pi = math.pi
-- ------- the shape of a disc
--
-- Radius in world pixels, where a map cell is 16 and the two mons stand three
-- cells apart.
--
-- A PLATFORM FOLLOWS WHAT STANDS ON IT rather than being one fixed size,
-- because the set's footprints run nearly tenfold: a Caterpie is under four
-- world pixels across and Moltres, wings out, is twenty-six. One radius for
-- both is either a dinner plate under the caterpillar or a doily under the
-- bird.
--
-- RADIUS is the floor, and it is the number most species land on -- it is a
-- little over the map cell a Pokemon is sized to cover, which is the
-- proportion the Game Boy's own battle platforms have. PAD is the margin
-- around a mon that needs more than that, and MAX_RADIUS stops Moltres from
-- being handed something the frame cannot hold.
--
-- These are the FULL radius, out to where the fade has finished; the solid
-- centre a Pokemon actually stands on is SOLID of it. PAD is sized so that a
-- mon's own footprint fits inside that centre rather than out over the
-- stipple -- 1.8 x 0.70 is a little over 1.25, so a big Pokemon still has
-- solid ground under its edges.
StadiumStage.RADIUS = 18
StadiumStage.MAX_RADIUS = 34
StadiumStage.PAD = 1.8
-- The platform for a mon of this footprint. `r` may be nil (nothing standing
-- there yet, which is every frame of the send-out before the model appears),
-- and then the platform is the plain one -- it has to be there BEFORE the
-- Pokemon lands on it.
function StadiumStage.radiusFor(r)
local want = (r or 0) * StadiumStage.PAD
if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end
if want > StadiumStage.MAX_RADIUS then return StadiumStage.MAX_RADIUS end
return want
end
-- Per-vertex shading, in the same terms StadiumRig lights the models with, so
-- a disc and the Pokemon standing on it agree about where the sun is. Fitted
-- to Voxel3D.FACE_SHADE's six values: the constant is the average, and each
-- axis term is half the spread between that axis's two faces.
local SHADE_BASE = 0.7725
local SHADE_X = 0.06
local SHADE_Y = 0.225
local SHADE_Z = 0.11
local function shadeFor(nx, ny, nz)
local s = SHADE_BASE + nx * SHADE_X + ny * SHADE_Y + nz * SHADE_Z
if s < 0.30 then return 0.30 end
if s > 1.00 then return 1.00 end
return s
end
-- ------- the texture
--
-- The platform is a FLAT painted disc that fades out at its rim -- no rim
-- wall, no thickness, the whole thing carried in one texture on one quad
-- lying on the ground plane. That is what the Game Boy's own battle
-- platforms are, and it is what keeps the stage from competing with the
-- Pokemon standing on it.
--
-- ------- why the fade is DITHERED
--
-- The scene shader discards any texel under half alpha outright (it has to:
-- that is what keeps a sprite's transparent corners out of the depth buffer).
-- So a smooth alpha ramp does not fade -- it comes out as a hard circle cut
-- at wherever the ramp crosses 0.5, which is the one thing this must not be.
--
-- The fade is therefore an ORDERED DITHER baked into the texture's alpha:
-- every texel is fully on or fully off, and the proportion that are on falls
-- away toward the rim. It is the same trick the sky already uses for its
-- bands (Sky.DITHER), it needs no shader change and so risks nothing in any
-- other pass, and on a mode built out of visible texels it reads as intended
-- rather than as a limitation.
--
-- The COLOUR is deliberately neutral. Everything the shader does to it after
-- this is the environment's: Voxel3D.tint carries the hour outdoors and the
-- room's own flat light indoors, and the shadow pass darkens whatever the
-- Pokemon standing on it occludes. So one texture is a sunlit platform, a
-- dusk platform and a cave platform, without a variant for each.
StadiumStage.TEX = 128
-- Where the solid centre ends, as a fraction of the disc's radius. Inside
-- this everything is opaque; from here to the rim the dither thins out.
StadiumStage.SOLID = 0.76
local TOP = { 0.74, 0.71, 0.63 }
local TOP_ALT = { 0.67, 0.64, 0.57 }
local texture = nil
-- A small deterministic scatter, for the surface itself. Not a random one: an
-- authored constant that happens to look unpatterned is worth more here than
-- a seed, because it can never change under a different Lua.
--
-- Quantised into blocks, so the surface reads as TEXELS rather than as noise.
-- Per-pixel it came out as a fine mottle that fought the dithered rim for
-- attention -- and the rim is the thing worth looking at. At this size the
-- grain is roughly the size of the voxels everywhere else in the mode.
StadiumStage.GRAIN = 4
local function grain(x, y)
local bx = (x - x % StadiumStage.GRAIN) / StadiumStage.GRAIN
local by = (y - y % StadiumStage.GRAIN) / StadiumStage.GRAIN
local v = (bx * 37 + by * 71 + ((bx * by) % 13) * 17) % 100
return v < 34
end
-- The 8x8 ordered (Bayer) matrix, as thresholds in 0..63. Ordered rather than
-- random because a random dither crawls: this pattern is fixed in the
-- texture, so the fade holds still while the camera drifts across it.
local BAYER = {
{ 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 },
}
function StadiumStage.texture()
if texture ~= nil then return texture or nil end
local ok, img = pcall(function()
local n = StadiumStage.TEX
local data = love.image.newImageData(n, n)
local half = (n - 1) / 2
local solid = StadiumStage.SOLID
for y = 0, n - 1 do
local dy = (y - half) / half
for x = 0, n - 1 do
local dx = (x - half) / half
local d = (dx * dx + dy * dy) ^ 0.5
-- how much of this texel's neighbourhood should survive: everything
-- inside the solid core, nothing past the rim, and a smooth ramp
-- between the two that the dither turns into a stipple
local cover
if d <= solid then
cover = 1.0
elseif d >= 1.0 then
cover = 0.0
else
local t = (d - solid) / (1.0 - solid)
cover = 1.0 - t * t * (3 - 2 * t) -- smoothstep, falling
end
local threshold = (BAYER[y % 8 + 1][x % 8 + 1] + 0.5) / 64
local a = (cover > threshold) and 1 or 0
local c = grain(x, y) and TOP_ALT or TOP
data:setPixel(x, y, c[1], c[2], c[3], a)
end
end
local image = love.graphics.newImage(data)
-- nearest, like every other texture in this mode: the grain and the
-- stipple are both meant to read as texels. Clamped rather than
-- repeating now that one texture covers the whole disc.
image:setFilter("nearest", "nearest")
image:setWrap("clampzero", "clampzero")
return image
end)
texture = (ok and img) or false
return texture or nil
end
-- ------- the mesh
--
-- One quad, lying flat on the ground plane, spanning -1..1 in x and z with
-- the whole texture stretched across it. The DISC is the texture's business,
-- not the geometry's -- everything outside the painted circle is alpha the
-- shader discards -- which is what "a flat texture that fades out at the
-- edges" means and what makes this four vertices rather than a hundred and
-- fifty.
--
-- Shaded as a face pointing straight up, because it is one.
local mesh = nil
local function build()
local s = shadeFor(0, 1, 0)
local verts = {
{ -1, 0, -1, 0, 0, s },
{ 1, 0, -1, 1, 0, s },
{ 1, 0, 1, 1, 1, s },
{ -1, 0, 1, 0, 1, s },
}
return Voxel3D.newMesh(verts, { 1, 2, 3, 1, 3, 4 })
end
function StadiumStage.mesh()
if mesh == nil then mesh = build() or false end
return mesh or nil
end
function StadiumStage.invalidate()
if texture and texture.release then pcall(texture.release, texture) end
if mesh and mesh.release then pcall(mesh.release, mesh) end
texture, mesh = nil, nil
end
-- How far under the ground plane the disc actually sits. A hair, and only so
-- that a flat-footed Pokemon's sole -- which is AT the ground plane -- is not
-- coplanar with it and left to the depth buffer's mercy.
StadiumStage.SINK = 0.06
-- Where one disc sits: centred on a cell, at the ground plane, so a Pokemon
-- placed at that same height stands ON it rather than in it.
function StadiumStage.matrix(x, groundY, z, radius)
radius = radius or StadiumStage.RADIUS
return Mat4.mul(Mat4.translate(x, groundY - StadiumStage.SINK, z),
Mat4.scale(radius, 1, radius))
end
-- The two platforms this frame, as (side, matrix) -- shared by the camera's
-- pass and the sun's, so the two can never disagree about where they are.
local function each(arena, groundY, fn)
local ok, Stadium = pcall(V.require, "Stadium")
for _, side in ipairs({ "enemy", "player" }) do
local cell = arena[side]
if cell then
local footprint = ok and Stadium and Stadium.footprint(side) or nil
fn(StadiumStage.matrix(cell[1], groundY, cell[2],
StadiumStage.radiusFor(footprint)))
end
end
end
-- ------- the synthetic arena
--
-- STADIUM B does not search the map, because it does not stand on it. The
-- arena is the same WIDE shape every other staged fight uses -- so the two
-- cells are three apart down the middle and BattleCam frames them exactly as
-- it always has -- just placed at a fixed spot rather than a found one.
--
-- Away from the origin on purpose. The coordinates run through the camera
-- solve, the sun's frustum fit and the projection to Game Boy pixels, and
-- putting a stage at (0, 0) is the kind of thing that hides a sign error for
-- months.
StadiumStage.ORIGIN = { 16, 16 }
function StadiumStage.arena(map)
local BattleArena = V.require("BattleArena")
local arena = BattleArena.at(StadiumStage.ORIGIN[1], StadiumStage.ORIGIN[2],
"wide")
if not arena then return nil end
-- the map is carried for its SKY and its palette only -- what kind of place
-- the fight is happening in -- never for its geometry
arena.map = map
arena.discs = true
return arena
end
-- ------- the draws
-- The discs, in the main pass. No wireframe: everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that, and a
-- disc is a turned solid with no grid to draw.
function StadiumStage.draw(arena, groundY)
if not (arena and arena.discs) then return end
local m = StadiumStage.mesh()
local tex = StadiumStage.texture()
if not (m and tex) then return end
Voxel3D.seams(false)
Voxel3D.glass(false)
each(arena, groundY, function(matrix) Voxel3D.draw(m, tex, matrix) end)
Voxel3D.glass(true)
Voxel3D.seams(true)
end
-- And into the sun, so the two Pokemon put real shadows on the platforms they
-- are standing on. Without this the shadow map is empty where the discs are
-- and a mon casts onto nothing at all -- which, with no ground behind it
-- either, reads as the pair floating.
function StadiumStage.cast(shadowMap, arena, groundY)
if not (arena and arena.discs and shadowMap) then return end
local m = StadiumStage.mesh()
local tex = StadiumStage.texture()
if not (m and tex) then return end
each(arena, groundY, function(matrix) shadowMap.draw(m, tex, matrix) end)
end
return StadiumStage
+24
View File
@@ -1159,6 +1159,30 @@ function Voxel3D.seams(on)
on and VoxelGrid.DARK or 0)
end
-- ADDITIVE for the length of a draw, or nil to put the pass back the way
-- it was found.
--
-- Exactly one thing asks for this: the flame and gas primitives on a
-- STADIUM battle model (Charmander's tail, Weezing's cloud -- see
-- StadiumRig). Those are light, not surface: they are drawn over a body
-- that is already in the depth buffer and they must ADD to it rather than
-- replace it, or the flame comes out as an opaque orange sticker.
--
-- Depth WRITES go off with the blend, and for the usual reason -- a
-- translucent thing that wrote depth would punch whatever comes after it
-- out of the frame. The test stays on, so a flame behind a tree is still
-- behind the tree.
function Voxel3D.blend(mode)
if not active then return end
if mode == "add" then
pcall(love.graphics.setBlendMode, "add", "alphamultiply")
pcall(love.graphics.setDepthMode, "lequal", false)
else
pcall(love.graphics.setBlendMode, "alpha", "alphamultiply")
pcall(love.graphics.setDepthMode, "lequal", true)
end
end
-- Whether what is drawn next may consult the glass mask. false for the
-- length of a sprite-sheet pass, true to put it back.
--
+28
View File
@@ -865,6 +865,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model))
end
ShadowMap.sprites(false)
-- and the STADIUM models, outside the sprite flag and un-snugged, for
-- the reasons the flat battle pass gives (BattleScene.castShadows):
-- these are geometry, not cut-outs
pcall(function()
local stageArena, stageY = V.require("OverworldBattle").stage()
if stageArena and stageArena.discs then
V.require("StadiumStage").cast(ShadowMap, stageArena, stageY or 0)
end
V.require("Stadium").cast(ShadowMap)
end)
ShadowMap.finish(sig)
end
@@ -1072,6 +1082,24 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
end
-- and, on the STADIUM rungs, the models -- the same skinned meshes the
-- flat pass and the sun already used this frame, drawn again through
-- THIS eye. Unlike the cards there is nothing per-eye about them: a
-- model faces its opponent, not the viewer, so both eyes see the same
-- pose from their own seats, which is what makes it read as solid.
--
-- On STADIUM B the platforms come with them. In a headset the world is
-- still drawn -- the player is standing IN it, which is the whole point
-- of the headset, so the rung's "no map" does not apply here -- and the
-- discs then read as a stage set down on the ground, which is what they
-- are.
pcall(function()
local stageArena, stageY = V.require("OverworldBattle").stage()
if stageArena and stageArena.discs then
V.require("StadiumStage").draw(stageArena, stageY or 0)
end
V.require("Stadium").draw(BattleBillboard.PULL)
end)
if battleTex.flash then Voxel3D.flatten(nil) end
-- and the MOVE ANIMATIONS, standing on the same arena: the
-- engine's own effects layer on the plane through both cells
+18 -2
View File
@@ -200,6 +200,14 @@ mod.content.render_pipelines:register("voxel", {
-- and the whole battle. Ahead of the active() gate below, because a 3D
-- battle does not require the free-roam mode to be switched on.
OverworldBattle.update(dt)
-- The one-time build of the Pokemon Stadium battle models out of the
-- player's own ROM, if there is one to build from and it has not been
-- done (see StadiumInstall). Rides this hook for the same reason the
-- battle does -- it is the tick that runs whatever is on the stack -- and
-- asks exactly once, on the first frame the player is actually in the
-- world, so it is never fighting the engine's own launcher for the
-- screen.
pcall(function() V.require("StadiumScreen").maybePush() end)
-- The horde, on the same always-running tick and for the same reason:
-- it owns no pass of the frame, it is a MODE over the overworld, and
-- it has to keep thinking while a warp's wipe covers the screen (the
@@ -422,7 +430,15 @@ local SETTINGS = {
-- decide nothing there and a dead switch on the menu reads as broken.
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift.",
.. "shot over the shoulder with a slow parallax drift. 2D-3D stands "
.. "the game's own pics up on their tiles. STADIUM A replaces them with "
.. "the Pokemon Stadium battle models, animated, playing the animation "
.. "the move being used actually calls for. STADIUM B stands those same "
.. "models on two discs against the sky instead of on the map, which "
.. "works everywhere -- including the caves and shop floors that have "
.. "nowhere to stage a fight. The STADIUM rungs only appear once the "
.. "models have been built: drop a Pokemon Stadium (US) ROM in the "
.. "baseroms folder and restart.",
when = function() return not VR.enabled() end, full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
@@ -496,7 +512,7 @@ mod.options:define(schema)
-- 5 V-GRID toggle the wireframe (new)
-- 6 T-SHIFT cycle the blur ladder (was 9)
-- 7 V-CURVE cycle the horizon bend (new)
-- 8 3D-BTL toggle overworld battles (new)
-- 8 3D-BTL cycle overworld battles (new)
-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key)
--
-- Only 6 arrives by the documented route. Game:keypressed answers the
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.5.5",
"version": "1.6.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
@@ -15,6 +15,6 @@
"engine_internals"
],
"affects_link": false,
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.",
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. The 3D-BTL row also offers two STADIUM rungs, which replace the flat battle pics with the Pokemon Stadium battle models -- all 151 species, animated, playing the animation the move being used calls for -- either on the map or on two discs against the sky; those need a Pokemon Stadium (US) ROM of your own dropped in a baseroms/ folder, and build themselves out of it on first run. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.",
"github": "DramaticShape/DramaticShapeVoxelMod"
}
+8 -2
View File
@@ -10,7 +10,8 @@ return {
changed = {
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"with 3D-BTL on 2D-3D, a battle draws over the map's nearest clear ground instead of over a white field",
"with 3D-BTL on STADIUM A or STADIUM B, the same fight is staged with the Pokemon Stadium battle models in place of the flat pics -- all 151 species, skinned and animated, playing the animation the move being used actually calls for. A stands them on the map; B stands them on two discs against the sky, which works on every map including the caves and shop floors that have nowhere to stage a fight",
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
@@ -31,7 +32,8 @@ return {
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change",
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"3D-BTL on hotkey 8 (2D-3D / STADIUM A / STADIUM B / OFF, 2D-3D by default), battles fought on the world map -- 2D-3D stands the game's own pics up on their tiles, and both STADIUM rungs replace them with the Pokemon Stadium battle models. The STADIUM rungs are only on the row once those models have been built (see below)",
"the STADIUM animations are driven from the fight: a move plays the animation that species' own battle table names for it (so DIG really does put Diglett into the ground), damage plays the hit reaction, fainting plays the faint and holds there, and a send-out plays the entrance. The eyes blink and go dizzy, and Charmander's tail flame and Weezing's gas are drawn over the body",
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
"VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
"VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job",
@@ -49,6 +51,9 @@ return {
"WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water",
"a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line",
"a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
"the STADIUM rungs need the Pokemon Stadium battle models, and the mod ships none of them -- they are that game's data. Drop a Pokemon Stadium (US) ROM in a baseroms/ folder beside the game and the 151 models are built out of it on a loading screen the first time it runs, in about ten seconds, into the save directory; until then the two rungs are simply not on the row. Once built, a rung declines per POKEMON rather than per battle: a species with no pack, a standing substitute doll, and the trainer's own pic before the send-out each fall back to the flat card on that side alone, with the other side keeping its model",
"the STADIUM rungs size a Pokemon by its own model against the set's median, with the range compressed -- the authored heights span sixteenfold, from Caterpie to Gyarados, and a shared over-the-shoulder shot cannot hold that. The order and the feel of the differences survive; the literal ratios do not",
"three species -- Exeggutor, Tangela and Magmar -- have standby loops that are corrupt in the source extraction, and are held at their bind pose so they stand still rather than coming apart",
"the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
@@ -71,6 +76,7 @@ return {
},
credits = {
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
{ who = "the Pokemon Stadium decompilation effort", for_ = "the battle model format STADIUM's models are extracted from (see model_extract/)" },
{ who = "The Khronos Group", for_ = "the OpenXR loader shipped unmodified in assets/vr (Apache-2.0; full license text alongside the DLL)" },
},
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
+168
View File
@@ -0,0 +1,168 @@
# Pokemon Stadium (US) — battle model export
All 151 battle Pokemon plus 64 other models from the same segment, in standard
formats. Regenerate straight from the ROM — stdlib only, no `make init`, no
splat, no crunch64:
```bash
model_extract/pipeline/build.py
```
Put a US 1.0 ROM in [baseroms/](baseroms/) (`.z64`, `.n64` or `.v64`), or pass
`--rom=PATH`. See [pipeline/README.md](pipeline/README.md) for the module layout,
how the ROM is unpacked, and the generated-effects notes.
```
viewer.html browse everything in the browser — open it directly
manifest.json every model + what each of its animations is used for
moves.json all 165 moves + the animation each species plays for them
glb/025_pikachu.glb glTF 2.0 binary: mesh, skeleton, skin, animations, textures
glb/x152_model.glb non-Pokemon models from the same segment (props, trophies…)
textures/025_pikachu/ the same textures as loose PNGs, named <n>_<w>x<h>.png
js/ viewer payloads, one per model, plus index.js and moves.js
```
`viewer.html` has a filterable picker for every model plus prev/next/random, a
**move picker** that jumps to whichever animation the current Pokemon plays for
that move, per-animation playback with a frame scrubber, an eye/texture-animation
selector, and texture/lighting/wireframe/skeleton toggles. It reads `js/`, not
`glb/`, because browsers block `fetch` of local files from `file://` — script
injection is what lets the page work when you just double-click it.
Sizes: 73 MB `glb/`, 54 MB `js/`, 7.4 MB `textures/`. Two flags if you want less:
`--no-js` skips the viewer payloads and `viewer.html` (leaving the glTF export
alone), and `--manifest-only` rebuilds just `manifest.json`.
`.glb` files are self-contained and open directly in Blender, Maya, Unity, Unreal,
three.js, Godot, Windows 3D Viewer, macOS Quick Look, and https://gltf-viewer.donmccurdy.com.
Source file `N.bin` holds species `N + 1`.
## Conventions
- Y up, +Z front, right-handed — glTF standard.
- Animations are authored at **30 fps**; keyframe times are `frame / 30`.
- Units are game units. Models are authored 10x and scaled down by the
`model_root` node, matching the geo layout's scale command.
- Textures are `CLAMP_TO_EDGE`, materials are `alphaMode: MASK` with cutoff 0.5
(N64 RGBA5551 has one bit of alpha). `doubleSided` follows the display list's
cull mode.
## Skeleton
The game keeps bone scale *out* of the matrix chain (`func_800143C0` in
[src/12D80.c](../src/12D80.c)): scale accumulates in its own stack, a bone's local
translation is pre-multiplied by the parent's accumulated scale, and the
accumulated scale is applied to the rows of the finished world matrix only at
draw time. glTF node TRS instead propagates scale multiplicatively to children,
so a 1:1 node mapping would be wrong wherever a non-uniformly scaled bone has
descendants.
Each game bone is therefore exported as two nodes:
| node | role |
| --- | --- |
| `boneNN` | pivot: `translation = t * accScale(parent)`, `rotation = R`, scale 1 |
| `boneNN_scale` | leaf child holding `scale = accScale(bone)`, so it cannot propagate |
The skin binds to the `boneNN_scale` nodes, whose world matrices then equal the
game's draw matrices exactly. Vertices are already in bone-local space and each is
rigidly bound to one bone, so all inverse bind matrices are identity.
This was verified by parsing each exported `.glb` back and diffing every joint
matrix against a reference implementation of the game's own math, over the bind
pose and four sampled frames of every animation. Worst-case disagreement is
~1e-2 game units on models spanning 20–40 units, entirely from storing rotations
as spec-normalised `SHORT` quaternions.
## Animation semantics
Per-species battle data lives in `assets/us/70D3A0.bin`, 0xB90 bytes per species,
DMA'd into the battle system by `func_84302658` via the `D_80075BD0[species-1]`
pointer table. It is an array of 0x10-byte entries; byte 0 of each entry is an
index into that Pokemon's animation list and byte 1 indexes the auxiliary list:
- **entries 0–164** — one per move, in the move ID order of
[oldnotes/stadium1/constants/move_constants.s](../oldnotes/stadium1/constants/move_constants.s).
Entry *n* gives the animation played when the Pokemon uses move *n + 1*.
- **entries 165+** — fixed battle contexts (idle, hit, faint, …).
Every one of the 151 species' tables indexes only animations that exist in that
species' list, which is what confirms the layout.
`manifest.json` reports, for each animation, the exact list of moves that trigger
it plus which context slots reference it. `moves.json` inverts that: every move,
and the animation each of the 151 species plays for it.
One caveat when reading move data: the table is **dense**. Every species has a
row for every move, including moves it can never learn, and those unreachable
rows overwhelmingly point at the species' generic reaction animation — the same
one the `hit` slot uses. So "118 species play Thunderbolt" is an artifact, not a
fact about the game. `moves.json` marks each row with `differsFromDefault` and
gives a `speciesWithOwnAnimation` count per move; treat those as the signal. The `animationSlots` section carries an
`evidence` field per slot:
- `code` — the battle code in `src/fragments/62` names the slot outright.
- `data` — inferred from what the referenced animation actually does, measured
across all 151 species. For example slot 167 is labelled `faint` because its
animation always ends far from the standing pose (the model drops to
0.03–0.84x idle height, or leaves the frame entirely for fliers), while slot
168's animation always ends at exactly idle height.
`endBehavior` reports what the animation player does past the last frame
(`func_80016FBC`): every animation in the game wraps back to `loopStartFrame`, so
one-shots like `faint` are ended by the battle state machine switching animation,
not by the player clamping. glTF has no loop flag, so importers will loop clips by
default.
The common layout, consistent across nearly every species:
| animation | role |
| --- | --- |
| 0 | idle / standby loop (all 151 species) |
| 1 | second idle-length animation, rarely referenced by the context slots |
| 2 | hit / damage reaction (149–151 species across slots 166, 178–181) |
| 3 … n-3 | attack animations, selected per move |
| n-2 | faint (slots 167, 177) |
| n-1 | entrance / return-to-idle cycle (slots 168, 183) |
## Texture animations (blinking, dizzy eyes)
The second animation list in the model root is a *texture* animation, not a
skeletal one (`src/18140.c`). Geo command `0x23` carries a channel index at
offset `0x02`; when it is `>= 0`, `func_800176DC` replaces that material's
texture every frame from a per-frame stream of texture-table indices.
Charmander's eyes are the clearest example — texture 2 is the open eye, 3 and 4
are blink frames, and 5–7 are the dizzy swirl:
| aux animation | frames | texture stream |
| --- | --- | --- |
| 0 | 10 | `2 2 3 3 4 4 4 3 3 2` — a blink |
| 2 | 122 | cycles `5 6 7` — confusion swirl |
| 4 | 18 | a slower blink |
The viewer plays these. Each skeletal animation is paired with the texture
animation the battle table most often sets alongside it, and the `eyes` dropdown
overrides that. glTF 2.0 has no texture-swap animation channel, so this data
lives in `js/` and `moves.json` rather than the `.glb` — the `.glb` files carry
the first frame's texture on each material.
## Known gaps
- **Move effect visuals are not here.** Geo command `0x24` does not draw
anything: `func_80014CB8` just records an attachment point (an id plus a world
position) on the Pokemon, and the battle system spawns particles there. Ids
1–14 are generic and used by nearly every species. So Charmander's tail flame,
beams, explosions and the like are drawn by the effect system in the battle
fragments and are not present in these model files — Charmander's texture set
contains eyes, claws, teeth and skin, and no flame.
- **The Poke Ball throw/open model was not found.** It is not in this segment,
no other `assets/us/**.bin` contains a model fragment, and scanning the ROM
ranges of fragments 62–64 for embedded model headers found none. What does
exist is Poke Ball *2D* artwork in fragment 29
(`fragments/29/fragment29_unk_bin_*`, flagged in the splat yaml). The throw is
most likely built from raw display lists rather than a geo-layout model.
- Files 151–214 are exported as `x<file>_model` with generic names. They are
props, trophies, minigame pieces and similar; only Surfing Pikachu (file 152,
the same 37-bone / 723-triangle rig as Pikachu) is named with confidence. They
carry no battle table, so their animations are left unnamed.
+5
View File
@@ -0,0 +1,5 @@
baserom.z64
*.z64
*.n64
*.v64
*:Zone.Identifier
+25
View File
@@ -0,0 +1,25 @@
# Put the ROM here
`pipeline/build.py` looks for a Pokemon Stadium (US 1.0) ROM in this folder:
model_extract/baseroms/baserom.z64
`.z64`, `.n64` and `.v64` byte orders are all accepted — the pipeline detects the
magic and normalises on load. Any ROM file dropped in this folder is picked up.
Expected md5 of the US 1.0 ROM: `ed1378bc12115f71209a77844965ba50`. A different
ROM still runs, but the build prints a warning since the offsets are keyed to
this revision.
Search order (first hit wins):
1. `model_extract/baseroms/baserom.z64`
2. `model_extract/baseroms/us/baserom.z64`
3. `baseroms/us/baserom.z64` at the repo root — the location `make init` uses
4. any `*.z64` / `*.n64` / `*.v64` in this folder
Or point at one explicitly:
model_extract/pipeline/build.py --rom=/path/to/baserom.z64
The ROM is not included and is not tracked by git.
+93
View File
@@ -0,0 +1,93 @@
# Export pipeline
End-to-end: `baserom.z64` in, everything in `model_extract/` out. **Stdlib only** —
no `make init`, no splat, no crunch64, no build directory.
```bash
model_extract/pipeline/build.py # finds the ROM automatically
model_extract/pipeline/build.py --rom=/path/to.z64 --out=/tmp/out
```
The ROM is looked for in `model_extract/baseroms/` first, so this folder stands
on its own; the repo's own `baseroms/us/` is the fallback. Search order:
1. `model_extract/baseroms/baserom.z64`
2. `model_extract/baseroms/us/baserom.z64`
3. `baseroms/us/baserom.z64` at the repo root — where `make init` expects it
4. any `*.z64` / `*.n64` / `*.v64` sitting in `model_extract/baseroms/`
`.z64`, `.n64` and `.v64` all work — the byte order is detected from the magic
and normalised on load. See [../baseroms/README.md](../baseroms/README.md).
| flag | effect |
| --- | --- |
| `--only=3,91` | restrict to those model file numbers (fast iteration) |
| `--no-glb` | skip the glTF binaries and PNG dumps |
| `--no-js` | skip the viewer payloads and `viewer.html` |
| `--no-effects` | skip the generated fire/gas stand-ins |
## Modules
| file | does |
| --- | --- |
| `rom.py` | byte-order fixup (.z64/.v64/.n64), md5 check, archive unpacking, Yay0 and PERS-SZP decompression |
| `fragment.py` | FRAGMENT module → geo layout walk, F3DEX2 execution, textures, skeleton, animations |
| `battle.py` | per-species battle tables, move names, animation-context slot meanings |
| `glb.py` | glTF 2.0 binary writer |
| `effects.py` | **generated** fire/gas stand-ins (see below) |
| `build.py` | driver: ties it together, writes manifests |
## Getting from ROM to models without the build system
Three steps, all in `rom.py`:
1. **Byte order.** `.z64` is native; `.v64` swaps byte pairs; `.n64` reverses
words. Detected from the magic and normalised on load.
2. **Archive.** The segment at `0x920000` starts with
`u32 tag, u32 0, u32 totalSize, u32 fileCount`, then one
`{u32 offset, u32 size, u32 pad[2]}` record per file. Only the top three
bytes of the first word are reliably zero — the model archive puts a nonzero
value in the low byte, which is the quirk `tools/unpack_asset.py` works
around too.
3. **Decompression.** Each entry is `PERS-SZP` (an 8-byte magic plus a header
size, wrapping a Yay0 stream). The Yay0 decoder is ~30 lines: a bitstream
where a 1 copies a literal byte and a 0 pulls a (distance, length) pair.
Verified by decompressing all 215 entries and diffing against what
`make init` produces: **215/215 byte-identical**.
The battle tables need one more hop — `D_80075BD0[species - 1]` lives in the main
code segment, so `Rom.vram_to_rom` converts `0x80075BD0` using the segment's
`start`/`vram` from the splat yaml.
## Generated effects
`effects.py` produces **original, procedurally generated** fire and gas. It is
not extracted game data, and everything it emits is tagged `generated: true` in
the manifest, the viewer payloads and the PNG filenames (`*_fx.png`).
This exists because the real effects are not in the model files at all. Geo
command `0x08` attaches a callback (`func_80014A60` calls `node->unk_10`), and
the model supplies only two empty display lists and zeroed scratch buffers for
it to fill. The callback lives in another fragment and has not been ported, so
there is no flame mesh or flame texture to extract — Charmander's texture set is
eyes, claws, teeth and skin.
The stand-ins are anchored to the exact bone the callback hangs off, so they sit
where the real effect would and follow the animation:
| callback | species | stand-in |
| --- | --- | --- |
| `0x810000D8` | Charmander, Charmeleon, Charizard, Magmar, Moltres | tail/crest flame |
| `0x81000108` | Ponyta, Rapidash, Moltres wings | small flame |
| `0x810000E0` | Gastly (only) | gas cloud |
Both are looping flipbooks built from tileable value noise, drawn on a pair of
crossed quads — glTF cannot billboard, so crossed quads are the portable way to
make them read from any angle. Sizes are expressed as a fraction of the model's
**height** and divided by the anchor bone's accumulated scale, so an effect comes
out the intended size wherever in the skeleton it hangs. Seeds derive from the
species number, so a given Pokemon always generates the same effect.
In the viewer they draw in a second pass with depth writes off — additive for
fire, alpha for gas — and there is a *generated effects* toggle to hide them.
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Per-species battle data.
`func_84302658` in src/fragments/62 DMAs a 0xB90-byte table per species out of
the 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. It is an
array of 0x10-byte entries: byte 0 is an index into that Pokemon's animation
list, byte 1 indexes the auxiliary (texture) animation list.
entries 0..164 one per move, so entry n drives move n + 1
entries 165+ fixed battle contexts
Every one of the 151 species' tables indexes only animations that species
actually has, which is what confirms the layout.
"""
import os
import struct
STRIDE = 0xB90
ENTRY = 0x10
N_MOVES = 165
# `evidence` records how far each label can be trusted:
# code - the battle code in src/fragments/62 names the slot outright
# data - inferred from what the referenced animation does, measured over all
# 151 species
CONTEXT_SLOTS = {
165: ('idle', 'code',
'The standby loop. func_8432B0A4 restores this slot whenever the Pokemon '
'returns to neutral, and it resolves to animation 0 for all 151 species.'),
166: ('hit', 'data',
'Resolves to animation 2 for 149/151 species, the same animation slots '
'178-181 use in the reaction paths.'),
167: ('faint', 'data',
'The referenced animation always ends far from the standing pose - the '
'model collapses to 0.03-0.84x its idle height, or leaves the frame '
'entirely for fliers.'),
168: ('entrance', 'code+data',
'The default slot in func_8430506C / func_8432AF70. The referenced '
'animation ends at exactly idle height, so it is a full cycle that '
'settles back into the standby pose.'),
169: ('reaction_169', 'data', 'Resolves to the idle animation for 139/151 species.'),
170: ('reaction_170', 'data', 'Split between the idle and hit animations.'),
171: ('reaction_171', 'data', 'Resolves to the idle animation for 138/151 species.'),
172: ('reaction_172', 'data', 'Resolves to the idle animation for 148/151 species.'),
173: ('reaction_173', 'data', 'Resolves to the hit animation for 97/151 species.'),
174: ('reaction_174', 'data', 'Resolves to the idle animation for 138/151 species.'),
175: ('struggle', 'code', 'Passed as slot 0xAF to func_84305A74.'),
176: ('idle_alt', 'code',
'Substituted for the idle slot when battle flag 0x200 is set.'),
177: ('faint_alt', 'data', 'Same animation as slot 167 for almost every species.'),
178: ('flinch', 'code',
'Used when the incoming move is one of the two listed in D_84384598.'),
179: ('reaction_179', 'data', 'Resolves to the hit animation for all 151 species.'),
180: ('reaction_180', 'data', 'Resolves to the hit animation for all 151 species.'),
181: ('reaction_181', 'data', 'Resolves to the hit animation for all 151 species.'),
182: ('reaction_182', 'data',
'Resolves to animation 0 for 136 species and animation 1 for the other 15.'),
183: ('entrance_alt', 'data', 'Same animation as slot 168 for every species.'),
184: ('idle_return', 'code', 'Passed as slot 0xB8 to func_84305A74.'),
}
MOVE_CONSTANTS = 'oldnotes/stadium1/constants/move_constants.s'
def load_move_names(repo_root='.'):
"""Move IDs come from the repo's own extracted constants when available."""
path = os.path.join(repo_root, MOVE_CONSTANTS)
names = {}
if os.path.exists(path):
for line in open(path, encoding='utf-8', errors='replace'):
# the file also carries an ABC_* section indexing moves alphabetically
# by their Japanese names; only the real move IDs are wanted
if ' EQU ' not in line or line.startswith('ABC_'):
continue
name, val = line.split(' EQU ')
val = val.split(';', 1)[0].strip()
if val:
names[int(val, 0)] = name.strip().replace('_', ' ').title()
return {i: names.get(i, f'Move {i}') for i in range(1, N_MOVES + 1)}
class BattleTables:
def __init__(self, rom):
from rom import BATTLE_DATA, PTR_TABLE_VRAM
self.rom = rom
self.base = BATTLE_DATA
self.ptr_table = rom.vram_to_rom(PTR_TABLE_VRAM)
def offset(self, species):
raw = self.rom.u32(self.ptr_table + (species - 1) * 4)
return self.base + (raw & 0xFFFFFF)
def rows(self, species):
"""Returns [(animIndex, auxIndex)] for every entry, aux 0xFF -> -1."""
o = self.offset(species)
out = []
for e in range(STRIDE // ENTRY):
anim = self.rom.data[o + e * ENTRY]
aux = self.rom.data[o + e * ENTRY + 1]
out.append((anim, -1 if aux == 0xFF else aux))
return out
SPECIES = {}
_NAMES = (
"Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
"Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
"Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
"NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
"Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
"Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
"Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
"Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
"Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
"Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
"Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
"Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
"Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
"Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
for _i, _n in enumerate(_NAMES):
SPECIES[_i + 1] = _n
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""
End-to-end export: baserom.z64 -> glb / textures / viewer payloads / manifests.
model_extract/pipeline/build.py [--rom PATH] [--out DIR] [options]
Stdlib only. Nothing here needs `make init`, splat or crunch64 -- the ROM is
read, decompressed and parsed directly.
Options:
--no-js skip the viewer payloads and viewer.html
--no-glb skip the glTF binaries and PNG dumps
--no-effects skip the generated fire/gas stand-ins
--only N[,N] restrict to these model file numbers (for quick iteration)
"""
import base64
import collections
import json
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
REPO = os.path.abspath(os.path.join(HERE, '..', '..'))
import battle
import effects as fx_gen
import fragment
import glb as glb_mod
import rom as rom_mod
N_POKEMON = 151
EXTRA_NAMES = {152: 'Surfing Pikachu'} # only the one identified with confidence
# Where to look for the ROM, in order. model_extract/baseroms/ comes first so the
# folder can stand on its own; the repo's own baseroms/ is the fallback.
BASEROMS = os.path.join(os.path.dirname(HERE), 'baseroms')
ROM_CANDIDATES = [
os.path.join(BASEROMS, 'baserom.z64'),
os.path.join(BASEROMS, 'us', 'baserom.z64'),
os.path.join(REPO, 'baseroms', 'us', 'baserom.z64'),
]
def find_rom():
for p in ROM_CANDIDATES:
if os.path.exists(p):
return p
if os.path.isdir(BASEROMS): # any ROM dropped in the folder
for f in sorted(os.listdir(BASEROMS)):
if f.lower().endswith(('.z64', '.n64', '.v64')):
return os.path.join(BASEROMS, f)
return None
# --------------------------------------------------------------- bind extent
def bind_extent(data):
"""World-space bounding box of the bind pose, used to size the effects."""
def trs(t, r, s):
S = lambda v: math.sin(v / 32768 * math.pi)
C = lambda v: math.cos(v / 32768 * math.pi)
sx, cx = S(r[0]), C(r[0]); sy, cy = S(r[1]), C(r[1]); sz, cz = S(r[2]), C(r[2])
return [cy*cz*s[0], cy*sz*s[0], -sy*s[0], 0,
(sx*sy*cz-cx*sz)*s[1], (sx*sy*sz+cx*cz)*s[1], sx*cy*s[1], 0,
(cx*sy*cz+sx*sz)*s[2], (cx*sy*sz-sx*cz)*s[2], cx*cy*s[2], 0,
t[0], t[1], t[2], 1]
def mul(a, b):
r = [0]*16
for c in range(4):
for i in range(4):
r[c*4+i] = a[i]*b[c*4] + a[4+i]*b[c*4+1] + a[8+i]*b[c*4+2] + a[12+i]*b[c*4+3]
return r
root = trs([0, 0, 0], [0, 0, 0], data['rootScale'])
acc, uns, mats = [], [], []
for b in data['bones']:
pa = acc[b['parent']] if b['parent'] >= 0 else [1.0, 1.0, 1.0]
pu = uns[b['parent']] if b['parent'] >= 0 else root
u = mul(pu, trs([b['t'][k]*pa[k] for k in range(3)], b['r'], [1, 1, 1]))
a = [pa[k]*b['s'][k] for k in range(3)]
m = list(u)
for k in range(4):
m[k] *= a[0]; m[4+k] *= a[1]; m[8+k] *= a[2]
acc.append(a); uns.append(u); mats.append(m)
lo = [1e9]*3; hi = [-1e9]*3
for p in data['prims']:
for i, bi in enumerate(p['skin']):
m = mats[bi]
x, y, z = p['pos'][i*3:i*3+3]
w = (m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13],
m[2]*x+m[6]*y+m[10]*z+m[14])
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
# height, not the largest dimension: sizing off the max would scale Moltres'
# flames to its wingspan
extent = (hi[1] - lo[1]) if lo[0] <= hi[0] else 1.0
# how much each bone scales its own local space, so effects can compensate
scales = [math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]) for m in mats]
return extent, scales
# ------------------------------------------------------------ animation names
# Which context name wins when several claim the same animation. The battle
# table points many slots at one clip, and these are the ones worth naming.
NAME_PREF = ['idle', 'hit', 'faint', 'entrance', 'struggle', 'flinch']
def label_animations(data, rows, moves):
"""Name each animation after what the battle table uses it for, and pair it
with the texture animation that table most often sets alongside it.
Mutates `data['anims']`, giving each a `name` and an `aux`, and hands back
the per-animation context and move lists the manifest reports. Factored out
of main() because the mod's packer (tools/stadium_pack.py) has to label them
exactly the same way for its output to be comparable with the Lua extractor
that reads the same ROM at runtime.
"""
entries = [r[0] for r in rows]
aux = [r[1] for r in rows]
uses = [[] for _ in data['anims']]
move_uses = [[] for _ in data['anims']]
for e, ai in enumerate(entries):
if ai >= len(uses):
continue
if e < battle.N_MOVES:
move_uses[ai].append(moves[e + 1])
elif e in battle.CONTEXT_SLOTS:
uses[ai].append(battle.CONTEXT_SLOTS[e][0])
pairs = [collections.Counter() for _ in data['anims']]
for e, ai in enumerate(entries):
if ai < len(pairs) and 0 <= aux[e] < len(data['auxAnims']):
pairs[ai][aux[e]] += 1
for i, a in enumerate(data['anims']):
ctx = sorted(set(uses[i]))
named = [n for n in NAME_PREF if n in ctx]
a['name'] = (named[0] if named else 'attack' if move_uses[i]
else ctx[0] if ctx else f'anim{i}')
a['aux'] = pairs[i].most_common(1)[0][0] if pairs[i] else -1
seen = {}
for a in data['anims']:
n = seen.get(a['name'], 0)
seen[a['name']] = n + 1
if n:
a['name'] = f'{a["name"]}_{n + 1}'
return uses, move_uses
def attach_effects(data, species, raw=False):
"""Append generated fire/gas prims + their flipbook textures.
`raw` matches fragment.extract's: the flipbook frames are already RGBA8, so
they are stored as-is rather than encoded, for the packer that wants pixels.
"""
if not data.get('fx'):
return 0
extent, bone_scale = bind_extent(data)
made = fx_gen.build_for(species, data['fx'], extent, bone_scale)
for e in made:
first = len(data['textures'])
for i, frame in enumerate(e['frames']):
rec = dict(index=-1, w=e['w'], h=e['h'], generated=True)
if raw:
rec['rgba'] = frame
else:
rec['png'] = ('data:image/png;base64,' + base64.b64encode(
fragment.png(e['w'], e['h'], frame)).decode())
data['textures'].append(rec)
g = e['geo']
data['prims'].append(dict(
tex=first, cull=0, texAnim=-1, texMap={},
generated=True, effect=e['kind'],
blend='add' if e['kind'] == 'fire' else 'alpha',
fxFrames=list(range(first, first + len(e['frames']))),
pos=g['pos'], uv=g['uv'], nrm=g['nrm'], skin=g['skin'], idx=g['idx']))
return len(made)
# --------------------------------------------------------------------- main
def main(argv):
args = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True) for a in argv}
rom_path = args.get('--rom') or find_rom()
outdir = args.get('--out') or os.path.join(REPO, 'model_extract')
want_js = '--no-js' not in args
want_glb = '--no-glb' not in args
want_fx = '--no-effects' not in args
only = {int(x) for x in args['--only'].split(',')} if '--only' in args else None
if not rom_path or not os.path.exists(rom_path):
sys.exit('ROM not found. Put a Pokemon Stadium (US 1.0) ROM at\n'
f' {os.path.join(BASEROMS, "baserom.z64")}\n'
'or pass --rom=PATH. Searched:\n '
+ '\n '.join(ROM_CANDIDATES))
print(f'reading {rom_path}')
rom = rom_mod.Rom(rom_path)
print(f' md5 {rom.md5}' + ('' if rom.is_expected_us else ' (NOT the expected US 1.0 ROM)'))
blobs = rom_mod.pokemon_models(rom)
print(f' {len(blobs)} model fragments')
tables = battle.BattleTables(rom)
moves = battle.load_move_names(REPO)
for sub in ('glb', 'textures', 'js'):
os.makedirs(os.path.join(outdir, sub), exist_ok=True)
manifest = dict(
source='Pokemon Stadium (US) 1.0', romMd5=rom.md5,
generator='model_extract/pipeline/build.py',
coordinateSystem='Y up, +Z front, units are game units (models authored 10x, '
'baked into the model_root node scale)',
frameRate=30,
generatedEffects='Prims and textures tagged generated:true are NOT extracted '
'game data -- see pipeline/effects.py.',
animationSlots={str(k): dict(name=v[0], evidence=v[1], description=v[2])
for k, v in battle.CONTEXT_SLOTS.items()},
pokemon=[], extra=[])
move_rows, anim_names, index, fx_count = {}, {}, [], 0
for fileno, blob in enumerate(blobs):
if only is not None and fileno not in only:
continue
try:
data = fragment.extract(blob, f'{fileno}.bin')
except Exception as exc:
print(f' skip {fileno}: {exc}')
continue
species = data['species']
pokemon = fileno < N_POKEMON
if pokemon:
rows = tables.rows(species)
uses, move_uses = label_animations(data, rows, moves)
slug = f'{species:03d}_{battle.SPECIES.get(species, str(species)).lower()}'
data['name'] = battle.SPECIES.get(species, f'#{species}')
move_rows[species] = [[rows[e][0], rows[e][1]]
for e in range(battle.N_MOVES)]
anim_names[species] = [a['name'] for a in data['anims']]
else:
slug = f'x{fileno:03d}_model'
data['name'] = EXTRA_NAMES.get(fileno, f'Model {fileno}')
for i, a in enumerate(data['anims']):
a['name'] = f'anim{i}'
a['aux'] = 0 if data['auxAnims'] else -1
nfx = attach_effects(data, species) if want_fx else 0
fx_count += nfx
pngs = [base64.b64decode(t['png'].split(',', 1)[1]) for t in data['textures']]
if want_glb:
with open(os.path.join(outdir, 'glb', slug + '.glb'), 'wb') as fp:
fp.write(glb_mod.build_glb(data, pngs))
texdir = os.path.join(outdir, 'textures', slug)
os.makedirs(texdir, exist_ok=True)
for i, (t, p) in enumerate(zip(data['textures'], pngs)):
tag = '_fx' if t.get('generated') else ''
with open(os.path.join(texdir, f'{i:02d}_{t["w"]}x{t["h"]}{tag}.png'), 'wb') as fp:
fp.write(p)
if want_js:
with open(os.path.join(outdir, 'js', slug + '.js'), 'w') as fp:
fp.write('PKMN_LOAD(' + json.dumps(data, separators=(',', ':')) + ');\n')
entry = dict(
species=species, name=data['name'], slug=slug,
group='pokemon' if pokemon else 'extra',
sourceFile=f'{fileno}.bin', glb=f'glb/{slug}.glb',
textureDir=f'textures/{slug}',
triangles=sum(len(p['idx']) // 3 for p in data['prims']),
vertices=sum(len(p['pos']) // 3 for p in data['prims']),
bones=len(data['bones']), textures=len(data['textures']),
generatedEffects=nfx,
animations=[dict(
index=i, name=a['name'], frames=a['frames'],
seconds=round(a['frames'] / 30.0, 3),
endBehavior='clamp' if (a['flags'] & 2) else 'wrap',
loopStartFrame=a['loopStart'],
**(dict(contexts=sorted(set(uses[i])),
moves=sorted(set(move_uses[i])),
moveCount=len(set(move_uses[i]))) if pokemon else {}))
for i, a in enumerate(data['anims'])])
(manifest['pokemon'] if pokemon else manifest['extra']).append(entry)
index.append(dict(species=species, name=data['name'], slug=slug,
group=entry['group'], triangles=entry['triangles'],
bones=entry['bones'], animations=len(data['anims'])))
print(f' {slug:<22} {len(data["anims"]):2d} anims {entry["triangles"]:5d} tris'
+ (f' +{nfx} effect' if nfx else ''))
# ---- move index ------------------------------------------------------
moves_out = []
if move_rows:
default_anim = {p['species']: next(
(a['index'] for a in p['animations'] if 'hit' in a.get('contexts', [])), -1)
for p in manifest['pokemon']}
for mid in range(1, battle.N_MOVES + 1):
users, tally, ndiff = [], collections.Counter(), 0
for sp in sorted(move_rows):
ai, ax = move_rows[sp][mid - 1]
name = anim_names[sp][ai] if ai < len(anim_names[sp]) else f'anim{ai}'
diff = ai != default_anim.get(sp, -1)
ndiff += diff
tally[name] += 1
users.append(dict(species=sp, animation=ai, animationName=name,
aux=ax, differsFromDefault=diff))
moves_out.append(dict(
id=mid, name=moves[mid], speciesWithOwnAnimation=ndiff,
animationNames=[dict(name=n, species=c) for n, c in tally.most_common()],
users=users))
with open(os.path.join(outdir, 'moves.json'), 'w') as fp:
json.dump(dict(source=manifest['source'], note=(
'Entry n of the per-species battle table (0-indexed) selects the '
'animation played when that Pokemon uses move n+1. The table is dense '
'- every species has a row for every move, including moves it can '
'never learn - and those unreachable rows overwhelmingly point at the '
'species\' generic reaction animation. Use differsFromDefault.'),
moves=moves_out), fp, indent=1)
with open(os.path.join(outdir, 'manifest.json'), 'w') as fp:
json.dump(manifest, fp, indent=2)
if want_js:
with open(os.path.join(outdir, 'js', 'index.js'), 'w') as fp:
fp.write('window.PKMN_INDEX = ' + json.dumps(index, separators=(',', ':')) + ';\n')
if moves_out:
with open(os.path.join(outdir, 'js', 'moves.js'), 'w') as fp:
fp.write('window.PKMN_MOVES = ' + json.dumps(
[dict(id=m['id'], name=m['name'],
bySpecies={str(u['species']): [u['animation'], u['aux']]
for u in m['users']}) for m in moves_out],
separators=(',', ':')) + ';\n')
viewer_src = os.path.join(REPO, 'tools/model_viewer/viewer.html')
if os.path.exists(viewer_src):
with open(viewer_src) as s, open(os.path.join(outdir, 'viewer.html'), 'w') as d:
d.write(s.read())
print(f'\n{len(manifest["pokemon"])} Pokemon + {len(manifest["extra"])} other models'
f', {fx_count} generated effects')
if __name__ == '__main__':
main(sys.argv[1:])
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Generated stand-in effects.
IMPORTANT: nothing in this file is extracted game data. The real tail flame,
mane fire and gas are drawn by procedural callbacks that live in another
fragment (geo command 0x08 -> func_80014A60 calls node->unk_10, and the model
file supplies only two empty display lists plus zeroed scratch buffers). Those
callbacks have not been ported, so the models genuinely contain no flame mesh
and no flame texture.
What follows is an original, procedurally generated replacement: looping
flipbook textures plus a pair of crossed quads anchored to the bone the
callback hangs off. It is meant to make the models look right in the viewer,
and it is tagged `generated: true` everywhere it appears so it is never
mistaken for ripped content.
"""
import math
# geo cmd 0x08 callback ids -> which effect to stand in for. The grouping is the
# game's own: every species sharing a callback shares an effect.
FIRE_TAIL = 0x810000D8 # Charmander, Charmeleon, Charizard, Magmar, Moltres
FIRE_SMALL = 0x81000108 # Ponyta, Rapidash, Moltres wings
AURA = 0x810000E0 # Gastly, Koffing, Weezing, Vaporeon, Articuno, Moltres
class Rng:
"""Deterministic PRNG so a given species always generates the same effect."""
def __init__(self, seed):
self.s = seed & 0xFFFFFFFF or 0x9E3779B9
def next(self):
x = self.s
x ^= (x << 13) & 0xFFFFFFFF
x ^= x >> 17
x ^= (x << 5) & 0xFFFFFFFF
self.s = x & 0xFFFFFFFF
return self.s
def unit(self):
return self.next() / 0x100000000
def _lattice(rng, w, h):
return [[rng.unit() for _ in range(w)] for _ in range(h)]
def _smooth(t):
return t * t * (3 - 2 * t)
def _sample(grid, x, y):
"""Bilinear value noise on a torus, so the field tiles in both axes."""
h, w = len(grid), len(grid[0])
x0, y0 = int(math.floor(x)) % w, int(math.floor(y)) % h
x1, y1 = (x0 + 1) % w, (y0 + 1) % h
fx, fy = _smooth(x - math.floor(x)), _smooth(y - math.floor(y))
a = grid[y0][x0] + (grid[y0][x1] - grid[y0][x0]) * fx
b = grid[y1][x0] + (grid[y1][x1] - grid[y1][x0]) * fx
return a + (b - a) * fy
def _fbm(grids, x, y, scale):
"""Sum octaves of tileable noise."""
total, amp, norm = 0.0, 1.0, 0.0
for i, g in enumerate(grids):
f = scale * (2 ** i)
total += _sample(g, x * f, y * f) * amp
norm += amp
amp *= 0.5
return total / norm
def _ramp(stops, t):
t = max(0.0, min(1.0, t))
for i in range(len(stops) - 1):
a, b = stops[i], stops[i + 1]
if t <= b[0]:
k = 0.0 if b[0] == a[0] else (t - a[0]) / (b[0] - a[0])
return tuple(int(a[1 + j] + (b[1 + j] - a[1 + j]) * k) for j in range(4))
return tuple(stops[-1][1:])
FIRE_RAMP = [ # intensity -> RGBA
(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),
]
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),
]
def fire_frames(seed, w=32, h=64, frames=8, wisp=1.0):
"""Upward-advected noise plume. Scrolling by an exact multiple of the noise
lattice over the frame count makes the loop seamless."""
rng = Rng(seed)
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
out = []
for f in range(frames):
t = f / frames
buf = bytearray(w * h * 4)
for y in range(h):
v = y / (h - 1) # 0 at the base, 1 at the tip
# plume envelope: wide and hot at the base, pinched at the tip
taper = max(0.0, 1.0 - v) ** 0.42
for x in range(w):
u = (x / (w - 1)) * 2 - 1 # -1 .. 1 across the flame
radial = (1.0 - min(1.0, abs(u) / max(0.10, taper * 0.95))) ** 0.7
if radial <= 0:
continue
n = _fbm(grids, x / w, (y / h) - t, 3.0)
lick = 0.55 + 0.75 * (n - 0.5) * wisp
inten = radial * (0.55 + 0.8 * taper) * lick
inten -= 0.16 * v # cool towards the tip
if inten <= 0.02:
continue
r, g, b, a = _ramp(FIRE_RAMP, inten)
i = ((h - 1 - y) * w + x) * 4 # +Y in texture space is up
buf[i:i+4] = bytes((r, g, b, a))
out.append(bytes(buf))
return w, h, out
def gas_frames(seed, w=48, h=48, frames=10):
"""Slow swirling haze that fades out towards the rim."""
rng = Rng(seed)
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
out = []
for f in range(frames):
t = f / frames
buf = bytearray(w * h * 4)
ang = t * 2 * math.pi
for y in range(h):
for x in range(w):
dx = (x / (w - 1)) * 2 - 1
dy = (y / (h - 1)) * 2 - 1
d = math.hypot(dx, dy)
if d >= 1.0:
continue
falloff = (1.0 - d) ** 0.85
# rotate the sample point so the haze churns without popping
sx = dx * math.cos(ang) - dy * math.sin(ang)
sy = dx * math.sin(ang) + dy * math.cos(ang)
n = _fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
inten = falloff * (0.78 + 1.30 * (n - 0.44))
if inten <= 0.03:
continue
r, g, b, a = _ramp(GAS_RAMP, inten)
i = (y * w + x) * 4
buf[i:i+4] = bytes((r, g, b, a))
out.append(bytes(buf))
return w, h, out
def crossed_quads(bone, length, width, axis='y', centred=False):
"""Two quads at right angles so the effect reads from any angle -- the
portable stand-in for a billboard, since glTF cannot billboard.
`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;
'y' is that same quad rotated 90 degrees left about Z, which stands it up.
`centred` straddles the origin instead of growing from it."""
pos, uv, nrm, skin, idx = [], [], [], [], []
for q in range(2):
base = len(pos) // 3
for (s, t) in ((0, 0), (1, 0), (1, 1), (0, 1)):
a = (s - 0.5) * width
b = (t - 0.5) * length if centred else t * length
if axis == 'x':
p = (b, a, 0.0) if q == 0 else (b, 0.0, a)
else: # (x, y) -> (-y, x)
p = (-a, b, 0.0) if q == 0 else (0.0, b, a)
pos += list(p)
uv += [s, 1.0 - t]
nrm += [0.0, 0.0, 1.0] if q == 0 else [1.0, 0.0, 0.0]
skin.append(bone)
idx += [base, base + 1, base + 2, base, base + 2, base + 3]
return dict(pos=pos, uv=uv, nrm=nrm, skin=skin, idx=idx)
# desired size as a fraction of the model's world-space extent
SIZES = {
'fire_tail': (0.40, 0.22), # length, width
'fire_small': (0.075, 0.042),
'gas': (1.05, 1.05),
}
def build_for(species, fx, extent, bone_scale):
"""Returns [{kind, bone, geo, w, h, frames}] for one model, or [].
`extent` is the model's world-space size and `bone_scale[i]` how much bone i
already scales its local space; dividing by it keeps every effect the size we
asked for regardless of where in the skeleton it hangs."""
out = []
for node in fx:
cb, bone = node['callback'], node['bone']
if bone < 0 or bone >= len(bone_scale):
continue
k = bone_scale[bone] or 1.0
if cb == FIRE_TAIL:
fl, fw = SIZES['fire_tail']
w, h, fr = fire_frames(species * 7919 + 1, 32, 64, 8)
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
elif cb == FIRE_SMALL:
fl, fw = SIZES['fire_small']
w, h, fr = fire_frames(species * 6271 + bone, 24, 40, 8, wisp=1.25)
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
elif cb == AURA and species == 92: # Gastly only
fl, fw = SIZES['gas']
w, h, fr = gas_frames(species * 5237 + 3, 48, 48, 10)
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y', centred=True)
out.append(dict(kind='gas', bone=bone, geo=geo, w=w, h=h, frames=fr))
return out
+735
View File
@@ -0,0 +1,735 @@
#!/usr/bin/env python3
"""
Model extraction: FRAGMENT module -> geometry, textures, skeleton, animations.
Self-contained copy of tools/model_viewer/extract_model.py, taking raw bytes so
it can be fed straight from the ROM. See ../README.md for the format notes.
"""
import json, os, struct, sys, zlib
BASE = 0x8FF00000
# ---------------------------------------------------------------- geo layout
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,
}
class Fragment:
def __init__(self, data, name='<bytes>'):
self.d = data if isinstance(data, (bytes, bytearray)) else open(data, 'rb').read()
self.name = name if isinstance(data, (bytes, bytearray)) else str(data)
if self.d[8:0x10] != b'FRAGMENT':
raise ValueError(f'{self.name}: not a FRAGMENT module')
self.hdrSize, self.relocOff, self.sizeRom, self.sizeRam = struct.unpack_from('>4I', self.d, 0x10)
def off(self, ptr):
return None if ptr == 0 else ptr - BASE
def u8(self, o): return self.d[o]
def s8(self, o): return struct.unpack_from('>b', self.d, o)[0]
def u16(self, o): return struct.unpack_from('>H', self.d, o)[0]
def s16(self, o): return struct.unpack_from('>h', self.d, o)[0]
def u32(self, o): return struct.unpack_from('>I', self.d, o)[0]
def s32(self, o): return struct.unpack_from('>i', self.d, o)[0]
def ptr(self, o): return self.off(self.u32(o))
def root(self):
"""The entry stub ends with `lui rX, hi; addiu rX, rX, lo` loading the root struct."""
for o in range(0x20, 0x80, 4):
w = self.u32(o)
if (w >> 26) != 0x0F: # lui
continue
reg = (w >> 16) & 0x1F
w2 = self.u32(o + 4)
if (w2 >> 26) == 0x09 and ((w2 >> 21) & 0x1F) == reg: # addiu rX, rX, imm
return ((self.u16(o + 2) << 16) + self.s16(o + 6)) - BASE
raise RuntimeError('could not locate root struct')
def ptr_list(self, o):
out = []
while True:
p = self.ptr(o)
if p is None:
return out
out.append(p)
o += 4
# --------------------------------------------------------------- F3DEX2 exec
def signed(v, bits):
m = 1 << (bits - 1)
return (v ^ m) - m
class Model:
"""Walks the geo layout, executes the display lists, accumulates draw data."""
def __init__(self, frag):
self.f = frag
r = frag.root()
self.species = frag.u16(r)
self.geoLayouts = frag.ptr_list(frag.ptr(r + 0x08))
self.anims = frag.ptr_list(frag.ptr(r + 0x0C))
self.auxAnims = frag.ptr_list(frag.ptr(r + 0x10))
self.textures = [] # {fmt, siz, w, h, texels, data}
self.tluts = [] # palettes: {count, data, dl}
self.bones = [] # {parent, boneId, chan, t, r, s}
self.boneById = {}
self.prims = [] # {tex, cull, verts:[...], tris:[...]}
self.primsByKey = {}
self.vtxBase = None
self.rootScale = [1.0, 1.0, 1.0]
self.fx = [] # geo cmd 0x08 procedural effect nodes
self.warnings = []
# ---- textures -------------------------------------------------------
def read_texture_table(self, off, count):
f = self.f
for i in range(count):
o = off + i * 0xC
self.textures.append(dict(
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)))
def read_tlut_table(self, off, count):
"""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 DL that loads
it (src/12D80.c func_80015B20). The DL is authoritative, so run it."""
f = self.f
for i in range(count):
o = off + i * 0xC
rec = dict(count=f.u16(o + 2), data=f.ptr(o + 4), dl=f.ptr(o + 8))
dl = rec['dl']
if dl is not None:
for _ in range(16):
w0, w1 = struct.unpack_from('>II', f.d, dl)
op = w0 >> 24
if op == 0xFD: # G_SETTIMG
rec['data'] = f.off(w1)
elif op == 0xF0: # G_LOADTLUT
rec['count'] = ((w1 >> 14) & 0x3FF) + 1
elif op == 0xDF:
break
dl += 8
self.tluts.append(rec)
# ---- geo layout -----------------------------------------------------
def build(self):
self.curTex = -1
self.curTlut = -1
self.curMat = None
self.curTexAnim = -1
# Mirrors gCurGraphNodeList in src/geo_layout.c: stack[-1] is the slot the
# next node command writes to, and a node's parent -- and the bone whose
# matrix is live -- is the slot *below* it (func_80017AC4).
self.stack = [-1]
# The RSP vertex cache persists across display lists: a bone's list often
# preloads verts 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 = [None] * 64
self.walk(self.geoLayouts[0])
def walk(self, o, depth=0):
f = self.f
if depth > 32:
return
while True:
cmd = f.u8(o)
size = CMD_SIZES.get(cmd)
if size is None:
self.warnings.append(f'unknown geo cmd {cmd:#04x} at {o:#x}')
return
if cmd == 0x01 or cmd == 0x04: # end / return
return
if cmd in (0x00, 0x03): # branch (with return)
self.walk(f.ptr(o + 4), depth + 1)
elif cmd == 0x02: # jump (no return)
o = f.ptr(o + 4)
continue
elif cmd == 0x05: # open node
self.stack.append(self.stack[-1])
elif cmd == 0x06: # close node
self.stack.pop()
elif cmd == 0x17: # model header
self.read_texture_table(f.ptr(o + 8), f.s16(o + 2))
if f.ptr(o + 0xC):
self.read_tlut_table(f.ptr(o + 0xC), f.s16(o + 4))
self.vtxBase = f.ptr(o + 0x10)
self.nVerts = f.s16(o + 6)
elif cmd == 0x08: # procedural effect callback
self.fx.append(dict(bone=self.curBone(), callback=f.u32(o + 4),
arg=f.ptr(o + 8)))
elif cmd == 0x1C: # uniform scale node
self.rootScale = [f.s32(o + 4) / 65536.0, f.s32(o + 8) / 65536.0,
f.s32(o + 0xC) / 65536.0]
elif cmd == 0x1D: # bone / joint node
idx = len(self.bones)
self.bones.append(dict(
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[-1] = idx
elif cmd == 0x23: # 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 from the
# auxiliary animation's channel stream.
self.curTexAnim = f.s16(o + 2)
elif cmd == 0x22: # display list on current bone
self.run_dl(f.ptr(o + 4), self.curBone())
elif cmd == 0x1E: # display list on named bone
self.run_dl(f.ptr(o + 4), self.boneById.get(f.s16(o + 2), self.curBone()))
elif cmd in (0x20, 0x21): # display list + own transform
self.run_dl(f.ptr(o + (0x10 if cmd == 0x20 else 0xC)), self.curBone())
o += size
def curBone(self):
return self.stack[-2] if len(self.stack) >= 2 else -1
# ---- display lists --------------------------------------------------
def run_dl(self, o, bone, depth=0):
if o is None or depth > 8:
return
f = self.f
vbuf = self.vbuf
cull = 0x400
while True:
w0, w1 = struct.unpack_from('>II', f.d, o)
op = w0 >> 24
o += 8
if op == 0xDF: # G_ENDDL
return
if op == 0xDE: # G_DL
self.run_dl(f.off(w1), bone, depth + 1)
if (w0 >> 16) & 0xFF: # branch, not call
return
continue
if op == 0x01: # G_VTX
n = (w0 >> 12) & 0xFF
v0 = ((w0 & 0xFFF) >> 1) - n
a = f.off(w1)
for i in range(n):
p = a + i * 0x10
if 0 <= v0 + i < len(vbuf):
vbuf[v0 + i] = (
f.s16(p), f.s16(p + 2), f.s16(p + 4), # position
f.s16(p + 8) / 32.0, f.s16(p + 10) / 32.0, # s, t (S10.5)
f.s8(p + 12), f.s8(p + 13), f.s8(p + 14), # normal
f.u8(p + 15), # alpha
bone) # owning bone
continue
if op == 0xD9: # G_GEOMETRYMODE
cull = (cull & (w0 & 0xFFFFFF)) | w1
continue
if op in (0x05, 0x06): # G_TRI1 / G_TRI2
prim = self.prim_for(self.curTex, self.curTlut, self.curMat,
self.curTexAnim, cull & 0x600)
def emit(a, b, c):
tri = []
for idx in (a, b, c):
v = vbuf[idx] if idx < len(vbuf) else None
if v is None:
return
j = prim['_remap'].get(v)
if j is None:
j = len(prim['verts'])
prim['_remap'][v] = j
prim['verts'].append(v)
tri.append(j)
if (cull & 0x200) and not (cull & 0x400):
tri.reverse()
prim['tris'].append(tri)
emit(((w0 >> 16) & 0xFF) // 2, ((w0 >> 8) & 0xFF) // 2, (w0 & 0xFF) // 2)
if op == 0x06:
emit(((w1 >> 16) & 0xFF) // 2, ((w1 >> 8) & 0xFF) // 2, (w1 & 0xFF) // 2)
continue
# everything else (SETTILE / sync / ...) is state we reconstruct
# from the texture table instead, so it is skipped.
def prim_for(self, tex, tlut, mat, texAnim, cull):
key = (tex, tlut, mat, texAnim, cull)
p = self.primsByKey.get(key)
if p is None:
p = dict(tex=tex, tlut=tlut, mat=mat, texAnim=texAnim, cull=cull,
verts=[], tris=[], _remap={})
self.primsByKey[key] = p
self.prims.append(p)
return p
def tile_palette(self, mat):
"""CI4 selects a 16-entry block of the TLUT via the render tile's palette
field; read it from the material display list's final G_SETTILE."""
if mat is None:
return 0
pal = 0
for _ in range(16):
w0, w1 = struct.unpack_from('>II', self.f.d, mat)
op = w0 >> 24
if op == 0xF5 and ((w1 >> 24) & 7) == 0: # G_SETTILE, render tile
pal = (w1 >> 20) & 0xF
elif op == 0xDF:
break
mat += 8
return pal
# ---------------------------------------------------------------- animations
def bitfield(f, base, index, bits):
"""src/F420.c func_80010500: signed `bits`-wide field at bit index*bits.
C integer division truncates toward zero; Python's floors. That only differs
for negative indices, which is exactly what an empty channel produces, so the
truncating form is used here to match the hardware."""
bitpos = index * bits
word = bitpos // 16 if bitpos >= 0 else -((-bitpos) // 16) # C: trunc to zero
rem = bitpos - word * 16 # C: sign follows bitpos
o = base + word * 2
v = (f.u16(o) << 16) | f.u16(o + 2)
v = (v << (rem & 31)) & 0xFFFFFFFF # MIPS masks the shift to 5 bits
return signed(v >> (32 - bits), bits)
class Animation:
"""src/17300.c. Two sampling modes: packed per-frame streams (default) and
hermite keyframes (flags & 8)."""
def __init__(self, frag, off):
f = self.f = frag
self.off = off
self.flags = f.u8(off)
self.startFrame= f.u16(off + 4)
self.loopStart = f.u16(off + 6)
self.nChannels = f.u16(off + 8)
self.nFrames = f.u16(off + 0xA)
self.chanTable = f.ptr(off + 0xC)
self.scaleData = f.ptr(off + 0x10)
self.rotData = f.ptr(off + 0x14)
self.transData = f.ptr(off + 0x18)
def chan(self, i):
o = self.chanTable + i * 0xA
f = self.f
return dict(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))
# -- packed stream sampling (flags & 8 == 0) --------------------------
# A count of 0 means the component has no stream at all. The game's 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 treated as "keep the bind-pose value".
def _trans_packed(self, c, frame):
if c['nTrans'] == 0:
return None
bits = 16 if (self.flags & 4) else 12
if c['nTrans'] == 1:
return float(c['oTrans'] if (self.flags & 4) else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4)
i = c['oTrans'] + min(frame, c['nTrans'] - 1)
return float(bitfield(self.f, self.transData, i, bits))
def _rot_packed(self, c, frame):
if c['nRot'] == 0:
return None
if c['nRot'] == 1:
return signed((c['oRot'] * 16) & 0xFFFF, 16)
i = c['oRot'] + min(frame, c['nRot'] - 1)
return signed((bitfield(self.f, self.rotData, i, 12) * 16) & 0xFFFF, 16)
def _scale_packed(self, c, frame):
if c['nScale'] == 0:
return None
if c['nScale'] == 1:
return c['oScale'] / 1000.0
i = c['oScale'] + min(frame, c['nScale'] - 1)
return self.f.s16(self.scaleData + i * 2) / 1000.0
# -- hermite keyframe sampling (flags & 8) ----------------------------
def _hermite(self, base, n, frame, wide):
f = self.f
stride = 8 if wide else 6
def key(i):
o = base + i * stride
return (f.s16(o), f.s16(o + 2), f.s16(o + 4),
f.s16(o + 6) if wide else f.s16(o + 4))
k0 = key(0)
if k0[0] >= frame:
return float(k0[1])
last = key(n - 1)
if frame >= last[0]:
return float(last[1])
i = 0
while i < n - 2:
if frame < key(i + 1)[0]:
break
i += 1
a, b = key(i), key(i + 1)
x = (frame - a[0]) / 30.0
y = 30.0 / (b[0] - a[0])
x2, x3 = x * x, x * x * x
y2, y3 = y * y, y * y * y
return (a[1] * (2 * x3 * y3 - 3 * x2 * y2 + 1)
+ b[1] * (-2 * x3 * y3 + 3 * x2 * y2)
+ (a[3] if wide else a[2]) * (x3 * y2 - 2 * x2 * y + x)
+ b[2] * (x3 * y2 - x2 * y))
def _trans_key(self, c, frame):
if c['nTrans'] < 2:
return float(signed(c['oTrans'], 16))
return self._hermite(self.transData + c['oTrans'] * 2, c['nTrans'], frame, c['interp'] & 1)
def _rot_key(self, c, frame):
if c['nRot'] < 2:
deg = signed(c['oRot'], 16) / 10.0
else:
deg = self._hermite(self.rotData + c['oRot'] * 2, c['nRot'], frame, c['interp'] & 2) / 10.0
deg %= 360.0
return int(deg / 360.0 * 65536.0)
def _scale_key(self, c, frame):
if c['nScale'] < 2:
return signed(c['oScale'], 16) / 100.0
return self._hermite(self.scaleData + c['oScale'] * 2, c['nScale'], frame, c['interp'] & 4) / 100.0
def sample_trs(self, chanIndex, frame, bind=None):
"""Returns (translation, rotation, scale) triples for one bone. Components
whose channel carries no data fall back to the bone's bind value."""
base = chanIndex * 3
if base < 0 or base + 2 >= self.nChannels:
return None
cs = [self.chan(base + i) for i in range(3)]
if self.flags & 8:
out = ([self._trans_key(c, frame) for c in cs],
[self._rot_key(c, frame) for c in cs],
[self._scale_key(c, frame) for c in cs])
else:
out = ([self._trans_packed(c, frame) for c in cs],
[self._rot_packed(c, frame) for c in cs],
[self._scale_packed(c, frame) for c in cs])
if bind is None:
bind = ([0, 0, 0], [0, 0, 0], [1.0, 1.0, 1.0])
return tuple([v if v is not None else bind[k][i] for i, v in enumerate(comp)]
for k, comp in enumerate(out))
class AuxAnimation:
"""Texture animation (src/18140.c). Same header shape as the skeletal
animations, but each channel is a per-frame stream of texture-table indices
that func_800176DC substitutes into a material."""
def __init__(self, frag, off):
f = self.f = frag
self.flags = f.u8(off)
self.startFrame= f.u16(off + 4)
self.loopStart = f.u16(off + 6)
self.nChannels = f.u16(off + 8)
self.nFrames = f.u16(off + 0xA)
self.chanTable = f.ptr(off + 0xC)
self.data = f.ptr(off + 0x10)
def sample(self, chan, frame):
"""func_80017540: index into the stream, clamped to the channel length."""
if not (0 <= chan < self.nChannels) or self.chanTable is None:
return None
o = self.chanTable + chan * 4
count, base = self.f.u16(o), self.f.u16(o + 2)
if count == 0:
return None
i = base + (frame if frame < count else count - 1)
return self.f.u8(self.data + i)
def track(self, chan):
n = max(1, self.nFrames)
return [self.sample(chan, i) for i in range(n)]
# ------------------------------------------------------------------ textures
def rgba5551(p):
return (((p >> 11) & 0x1F) * 255 // 31, ((p >> 6) & 0x1F) * 255 // 31,
((p >> 1) & 0x1F) * 255 // 31, 255 if (p & 1) else 0)
def decode_texture(f, tex, tlut=None, palette=0):
"""Returns (w, h, RGBA8 bytes) for the N64 texture formats these models use."""
w, h, fmt, siz, addr = tex['w'], tex['h'], tex['fmt'], tex['siz'], tex['data']
out = bytearray(w * h * 4)
d = f.d
n = w * h
def nibble(i):
return (d[addr + i // 2] >> (0 if i & 1 else 4)) & 0xF
if fmt == 0 and siz == 2: # RGBA16 (5/5/5/1)
for i in range(n):
out[i*4:i*4+4] = bytes(rgba5551(struct.unpack_from('>H', d, addr + i * 2)[0]))
elif fmt == 0 and siz == 3: # RGBA32
out[:] = d[addr:addr + n * 4]
elif fmt == 2: # CI4 / CI8 -> RGBA16 palette
pal = []
if tlut is not None and tlut['data'] is not None:
base = tlut['data'] + (palette * 16 * 2 if siz == 0 else 0)
for i in range(16 if siz == 0 else 256):
pal.append(bytes(rgba5551(struct.unpack_from('>H', d, base + i * 2)[0])))
if not pal:
pal = [b'\xff\x00\xff\xff'] * 256
for i in range(n):
idx = nibble(i) if siz == 0 else d[addr + i]
out[i*4:i*4+4] = pal[idx % len(pal)]
elif fmt == 3: # IA16 / IA8 / IA4
for i in range(n):
if siz == 2:
v = struct.unpack_from('>H', d, addr + i * 2)[0]
l, a = v >> 8, v & 0xFF
elif siz == 1:
v = d[addr + i]
l, a = (v >> 4) * 17, (v & 0xF) * 17
else:
v = nibble(i)
l, a = ((v >> 1) * 255) // 7, 255 if (v & 1) else 0
out[i*4:i*4+4] = bytes((l, l, l, a))
elif fmt == 4: # I8 / I4
for i in range(n):
l = d[addr + i] if siz == 1 else nibble(i) * 17
out[i*4:i*4+4] = bytes((l, l, l, 255))
else:
for i in range(n): # unsupported: magenta
out[i*4:i*4+4] = b'\xff\x00\xff\xff'
return w, h, bytes(out)
def png(w, h, rgba):
"""Minimal PNG encoder (no PIL dependency)."""
raw = b''.join(b'\x00' + rgba[y*w*4:(y+1)*w*4] for y in range(h))
def chunk(tag, data):
c = tag + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF)
return (b'\x89PNG\r\n\x1a\n'
+ chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(raw, 9))
+ chunk(b'IEND', b''))
# ---------------------------------------------------------------------- main
def unique(seq):
"""The distinct values of `seq`, in the order they first appear.
Used where a set used to be. A set of small ints iterates in hash-slot
order, which is stable across runs but is neither insertion nor sort order
and is a CPython implementation detail -- and here it decided the order
textures get REGISTERED in, and so their indices in the packed file. Order
of appearance is a property of the data instead of the interpreter, which
is what lets the Lua extractor produce the same file.
"""
seen, out = set(), []
for v in seq:
if v in seen:
continue
seen.add(v)
out.append(v)
return out
def dedupe_fx(nodes):
"""The geo layout's effect callbacks, once each, IN THE ORDER THEY APPEAR.
A geo layout can name the same callback on the same bone more than once
(the walk visits a subtree twice), so these have to be deduplicated, and it
used to be done by dropping them through a set. That was a real bug rather
than a style point: the set held tuples containing strings, so its iteration
order moved with PYTHONHASHSEED, and the generated flames of every species
carrying more than one -- Ponyta, Rapidash and Moltres -- came out in a
different order, with different seeds and therefore different pixels, on
different runs of the same build.
Order of appearance is the game's own order, it is stable, and it is what
the Lua extractor can reproduce.
"""
seen, out = set(), []
for node in nodes:
key = (node['bone'], node['callback'], node['arg'])
if key in seen:
continue
seen.add(key)
out.append(dict(node))
return out
def extract(path, name=None, raw=False):
"""`raw=True` carries each texture's decoded RGBA8 bytes as `rgba` instead
of encoding a PNG data URI into `png`.
The viewer and the glTF export both want a PNG, so that stays the default.
The mod's own packer wants the pixels: it stores them uncompressed, so that
its Lua counterpart -- which has no zlib whose output is guaranteed to
agree with this one's byte for byte -- can be checked against it exactly.
"""
f = Fragment(path, name or str(path))
m = Model(f)
m.build()
import base64
auxAnims = [AuxAnimation(f, o) for o in m.auxAnims]
texIndexMap, texOut = {}, []
def register(texIdx, tlut, pal):
key = (texIdx, tlut, pal)
if key in texIndexMap:
return texIndexMap[key]
if not (0 <= texIdx < len(m.textures)):
return -1
texIndexMap[key] = len(texOut)
tl = m.tluts[tlut] if 0 <= tlut < len(m.tluts) else None
w, h, rgba = decode_texture(f, m.textures[texIdx], tl, pal)
rec = dict(index=texIdx, w=w, h=h)
if raw:
rec['rgba'] = rgba
else:
rec['png'] = ('data:image/png;base64,'
+ base64.b64encode(png(w, h, rgba)).decode())
texOut.append(rec)
return texIndexMap[key]
for p in m.prims:
if not p['tris']:
continue
pal = m.tile_palette(p['mat'])
register(p['tex'], p['tlut'], pal)
# An animated material can swap to any texture its channel names, so all
# of them have to be decoded up front.
if p['texAnim'] >= 0:
for a in auxAnims:
for t in unique(a.track(p['texAnim'])):
if t is not None:
register(t, p['tlut'], pal)
prims = []
for p in m.prims:
if not p['tris']:
continue
pos, uv, nrm, skin = [], [], [], []
pal = m.tile_palette(p['mat'])
ti = texIndexMap.get((p['tex'], p['tlut'], pal), -1)
# texture-table index -> slot in texOut, for the animated swap
texMap = {}
if p['texAnim'] >= 0:
for a in auxAnims:
for t in unique(a.track(p['texAnim'])):
if t is not None and (t, p['tlut'], pal) in texIndexMap:
texMap[t] = texIndexMap[(t, p['tlut'], pal)]
tw, th = (m.textures[p['tex']]['w'], m.textures[p['tex']]['h']) if ti >= 0 else (32, 32)
for v in p['verts']:
pos += [v[0], v[1], v[2]]
uv += [v[3] / tw, v[4] / th]
nrm += [v[5] / 127.0, v[6] / 127.0, v[7] / 127.0]
skin.append(v[9])
prims.append(dict(tex=ti, cull=p['cull'], texAnim=p['texAnim'],
texMap={str(k): v for k, v in sorted(texMap.items())},
pos=pos, uv=uv, nrm=nrm, skin=skin,
idx=[i for t in p['tris'] for i in t]))
def compress(values, nd):
"""Constant tracks collapse to a scalar; most channels never move."""
r = [round(v, nd) for v in values]
return r[0] if all(v == r[0] for v in r) else r
anims = []
for i, off in enumerate(m.anims):
a = Animation(f, off)
nf = max(1, a.nFrames)
tracks = []
for b in m.bones:
ch = b['chan']
bind = (b['t'], b['r'], b['s'])
if ch < 0 or a.sample_trs(ch, 0, bind) is None:
tracks.append(None)
continue
samples = [a.sample_trs(ch, fr, bind) for fr in range(nf)]
tracks.append(dict(
t=[compress([s[0][k] for s in samples], 3) for k in range(3)],
r=[compress([s[1][k] for s in samples], 0) for k in range(3)],
s=[compress([s[2][k] for s in samples], 5) for k in range(3)]))
anims.append(dict(index=i, frames=nf, flags=a.flags,
channels=a.nChannels, loopStart=a.loopStart, tracks=tracks))
auxOut = []
for i, a in enumerate(auxAnims):
auxOut.append(dict(index=i, frames=max(1, a.nFrames), flags=a.flags,
loopStart=a.loopStart,
channels=[a.track(c) for c in range(a.nChannels)]))
return dict(
species=m.species,
name=SPECIES.get(m.species, f'#{m.species}'),
file=os.path.basename(f.name),
rootScale=m.rootScale,
bones=[dict(parent=b['parent'], boneId=b['boneId'], chan=b['chan'],
flags=b['flags'], t=b['t'], r=b['r'], s=b['s']) for b in m.bones],
textures=texOut,
prims=prims,
anims=anims,
auxAnims=auxOut,
fx=dedupe_fx(m.fx),
warnings=m.warnings,
)
SPECIES = {}
_NAMES = (
"Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
"Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
"Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
"NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
"Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
"Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
"Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
"Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
"Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
"Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
"Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
"Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
"Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
"Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
for _i, _n in enumerate(_NAMES):
SPECIES[_i + 1] = _n
if __name__ == '__main__':
here = os.path.dirname(os.path.abspath(__file__))
src = sys.argv[1] if len(sys.argv) > 1 else 'assets/us/pokemon_models/24.bin'
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(here, 'model.js')
data = extract(src)
body = json.dumps(data, separators=(',', ':'))
with open(dst, 'w') as fp:
fp.write('window.PKMN_MODEL = ' + body + ';\n')
tris = sum(len(p['idx']) // 3 for p in data['prims'])
print(f"{data['name']} (#{data['species']}) bones={len(data['bones'])} prims={len(data['prims'])} "
f"tris={tris} textures={len(data['textures'])} anims={len(data['anims'])}")
print(f"frames per anim: {[a['frames'] for a in data['anims']]}")
if data['warnings']:
print('warnings:', data['warnings'][:5])
print(f'wrote {dst} ({os.path.getsize(dst)/1024:.0f} KB)')
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
glTF 2.0 binary writer.
Each game bone becomes two nodes -- a pivot carrying translation/rotation and a
leaf carrying the accumulated scale -- because the game keeps scale out of the
matrix chain while glTF propagates it to children. See ../README.md.
"""
import json
import math
import struct
ND = 7 # decimals kept on node rest transforms
def quat_from_euler(r):
"""The game's rotation is Rx*Ry*Rz in row-vector form (src/F420.c
func_8000F730); build that basis as glTF columns and convert."""
sx, cx = math.sin(r[0] / 32768 * math.pi), math.cos(r[0] / 32768 * math.pi)
sy, cy = math.sin(r[1] / 32768 * math.pi), math.cos(r[1] / 32768 * math.pi)
sz, cz = math.sin(r[2] / 32768 * math.pi), math.cos(r[2] / 32768 * math.pi)
# rows of the game matrix become the columns of the glTF rotation
m = ((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))
tr = m[0][0] + m[1][1] + m[2][2]
if tr > 0:
s = math.sqrt(tr + 1.0) * 2
w = 0.25 * s
x = (m[2][1] - m[1][2]) / s
y = (m[0][2] - m[2][0]) / s
z = (m[1][0] - m[0][1]) / s
elif m[0][0] > m[1][1] and m[0][0] > m[2][2]:
s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2
w = (m[2][1] - m[1][2]) / s
x = 0.25 * s
y = (m[0][1] + m[1][0]) / s
z = (m[0][2] + m[2][0]) / s
elif m[1][1] > m[2][2]:
s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2
w = (m[0][2] - m[2][0]) / s
x = (m[0][1] + m[1][0]) / s
y = 0.25 * s
z = (m[1][2] + m[2][1]) / s
else:
s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2
w = (m[1][0] - m[0][1]) / s
x = (m[0][2] + m[2][0]) / s
y = (m[1][2] + m[2][1]) / s
z = 0.25 * s
n = math.sqrt(x*x + y*y + z*z + w*w) or 1.0
return [x/n, y/n, z/n, w/n]
def pose(bones, sample_fn):
"""Returns (pivotT, pivotQ, jointS) for every bone at one instant."""
acc, pt, pq, js = [], [], [], []
for i, b in enumerate(bones):
t, r, s = sample_fn(i, b)
pa = acc[b['parent']] if b['parent'] >= 0 else (1.0, 1.0, 1.0)
pt.append([t[0]*pa[0], t[1]*pa[1], t[2]*pa[2]])
pq.append(quat_from_euler(r))
a = (pa[0]*s[0], pa[1]*s[1], pa[2]*s[2])
acc.append(a)
js.append(list(a))
return pt, pq, js
# ------------------------------------------------------------------ glTF build
class Glb:
def __init__(self):
self.buf = bytearray()
self.views = []
self.accessors = []
def view(self, data, target=None):
while len(self.buf) % 4:
self.buf.append(0)
off = len(self.buf)
self.buf += data
v = dict(buffer=0, byteOffset=off, byteLength=len(data))
if target:
v['target'] = target
self.views.append(v)
return len(self.views) - 1
def accessor(self, data, ctype, atype, count, target=None,
minmax=None, normalized=False):
a = dict(bufferView=self.view(data, target), componentType=ctype,
count=count, type=atype)
if normalized:
a['normalized'] = True
if minmax:
a['min'], a['max'] = minmax
self.accessors.append(a)
return len(self.accessors) - 1
def floats(self, values, atype, target=None, minmax=None):
n = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}[atype]
return self.accessor(struct.pack(f'<{len(values)}f', *values),
5126, atype, len(values) // n, target, minmax)
def finish(self, gltf):
gltf['buffers'] = [dict(byteLength=len(self.buf))]
gltf['bufferViews'] = self.views
gltf['accessors'] = self.accessors
js = json.dumps(gltf, separators=(',', ':')).encode()
js += b' ' * (-len(js) % 4)
bin_ = bytes(self.buf) + b'\0' * (-len(self.buf) % 4)
return (struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(bin_))
+ struct.pack('<II', len(js), 0x4E4F534A) + js
+ struct.pack('<II', len(bin_), 0x004E4942) + bin_)
def build_glb(data, pngs):
bones = data['bones']
nb = len(bones)
g = Glb()
gltf = dict(asset=dict(version='2.0',
generator='pokestadium tools/model_viewer/export_gltf.py'))
# ---- nodes: root scale, then a pivot/joint pair per bone ----------------
bind_t, bind_q, bind_s = pose(bones, lambda i, b: (b['t'], b['r'], b['s']))
nodes = [dict(name='model_root', scale=[round(v, 6) for v in data['rootScale']])]
pivot_id = [0] * nb
joint_id = [0] * nb
for i, b in enumerate(bones):
pivot_id[i] = len(nodes)
nodes.append(dict(name=f'bone{b["boneId"]:02d}',
translation=[round(v, ND) for v in bind_t[i]],
rotation=[round(v, ND) for v in bind_q[i]]))
joint_id[i] = len(nodes)
nodes.append(dict(name=f'bone{b["boneId"]:02d}_scale',
scale=[round(v, ND) for v in bind_s[i]]))
nodes[pivot_id[i]]['children'] = [joint_id[i]]
for i, b in enumerate(bones):
parent = pivot_id[b['parent']] if b['parent'] >= 0 else 0
nodes[parent].setdefault('children', []).append(pivot_id[i])
# ---- textures / materials ---------------------------------------------
images, samplers, textures, materials = [], [], [], []
if pngs:
samplers.append(dict(magFilter=9729, minFilter=9729,
wrapS=33071, wrapT=33071)) # LINEAR, CLAMP
for i, blob in enumerate(pngs):
images.append(dict(mimeType='image/png',
bufferView=g.view(blob), name=f'tex{i:02d}'))
textures.append(dict(sampler=0, source=i))
prims_out = []
for p in data['prims']:
nv = len(p['pos']) // 3
pos = [float(v) for v in p['pos']]
mn = [min(pos[k::3]) for k in range(3)]
mx = [max(pos[k::3]) for k in range(3)]
attrs = dict(
POSITION=g.floats(pos, 'VEC3', 34962, (mn, mx)),
NORMAL=g.floats([float(v) for v in p['nrm']], 'VEC3', 34962),
TEXCOORD_0=g.floats([float(v) for v in p['uv']], 'VEC2', 34962),
JOINTS_0=g.accessor(
struct.pack(f'<{nv*4}H', *[v for j in p['skin'] for v in (j, 0, 0, 0)]),
5123, 'VEC4', nv, 34962),
WEIGHTS_0=g.floats([v for _ in range(nv) for v in (1.0, 0.0, 0.0, 0.0)],
'VEC4', 34962),
)
idx = g.accessor(struct.pack(f'<{len(p["idx"])}H', *p['idx']),
5123, 'SCALAR', len(p['idx']), 34963)
blend = p.get('blend')
mat = dict(
name=f'mat{len(materials):02d}',
alphaMode='BLEND' if blend else 'MASK',
doubleSided=bool(blend) or not (p['cull'] & 0x400),
pbrMetallicRoughness=dict(metallicFactor=0.0, roughnessFactor=0.9),
)
if blend:
# generated effects are unlit so they read as emissive fire/gas
mat['emissiveFactor'] = [1.0, 1.0, 1.0]
else:
mat['alphaCutoff'] = 0.5
if p['tex'] >= 0:
mat['pbrMetallicRoughness']['baseColorTexture'] = dict(index=p['tex'])
if blend:
mat['emissiveTexture'] = dict(index=p['tex'])
materials.append(mat)
prims_out.append(dict(attributes=attrs, indices=idx,
material=len(materials) - 1))
skin_node = len(nodes)
nodes.append(dict(name=data['name'], mesh=0, skin=0))
ident = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
gltf['skins'] = [dict(joints=joint_id, skeleton=0,
inverseBindMatrices=g.floats(ident * nb, 'MAT4'))]
gltf['meshes'] = [dict(name=data['name'], primitives=prims_out)]
# ---- animations --------------------------------------------------------
anims = []
for a in data['anims']:
nf = a['frames']
times = [round(fr / 30.0, 6) for fr in range(nf)] # authored at 30 fps
def sample_fn(i, b, _a=a):
tr = _a['tracks'][i]
if not tr:
return b['t'], b['r'], b['s']
pick = lambda c, fr: (c if isinstance(c, (int, float))
else c[min(fr, len(c) - 1)])
return ([pick(c, sample_fn.fr) for c in tr['t']],
[pick(c, sample_fn.fr) for c in tr['r']],
[pick(c, sample_fn.fr) for c in tr['s']])
seq_t = [[] for _ in range(nb)]
seq_q = [[] for _ in range(nb)]
seq_s = [[] for _ in range(nb)]
for fr in range(nf):
sample_fn.fr = fr
pt, pq, js = pose(bones, sample_fn)
for i in range(nb):
if seq_q[i] and sum(x*y for x, y in zip(seq_q[i][-1], pq[i])) < 0:
pq[i] = [-v for v in pq[i]] # keep quaternions continuous
seq_t[i].append(pt[i]); seq_q[i].append(pq[i]); seq_s[i].append(js[i])
channels, samplers_a = [], []
cache = {}
def time_accessor(keys):
if keys not in cache:
t = times if keys == nf else [times[0], times[-1]]
cache[keys] = g.floats(t, 'SCALAR', minmax=([t[0]], [t[-1]]))
return cache[keys]
for i in range(nb):
for seq, path, node, dflt in (
(seq_t[i], 'translation', pivot_id[i], nodes[pivot_id[i]]['translation']),
(seq_q[i], 'rotation', pivot_id[i], nodes[pivot_id[i]]['rotation']),
(seq_s[i], 'scale', joint_id[i], nodes[joint_id[i]]['scale'])):
const = all(v == seq[0] for v in seq)
# A constant channel can only be dropped when it already equals the
# node's rest value; otherwise the node would sit in its bind pose.
if const and [round(c, ND) for c in seq[0]] == dflt:
continue
if const:
seq = [seq[0], seq[0]]
time_acc = time_accessor(len(seq))
flat = [c for v in seq for c in v]
if path == 'rotation':
out = g.accessor(
struct.pack(f'<{len(flat)}h',
*[max(-32768, min(32767, round(c * 32767)))
for c in flat]),
5122, 'VEC4', len(seq), normalized=True)
else:
out = g.floats(flat, 'VEC3')
samplers_a.append(dict(input=time_acc, output=out,
interpolation='LINEAR'))
channels.append(dict(sampler=len(samplers_a) - 1,
target=dict(node=node, path=path)))
if channels:
anims.append(dict(name=a['name'], channels=channels, samplers=samplers_a))
if anims:
gltf['animations'] = anims
gltf['nodes'] = nodes
gltf['scenes'] = [dict(nodes=[0, skin_node])]
gltf['scene'] = 0
if images:
gltf['images'] = images
gltf['samplers'] = samplers
gltf['textures'] = textures
gltf['materials'] = materials
return g.finish(gltf)
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Raw ROM access for the Pokemon Stadium (US) model export.
Everything here is stdlib-only: byte-order fixup, the Yay0 decompressor, the
PERS-SZP wrapper the assets use, and the little archive format that packs many
files into one segment. That is all it takes to get from baserom.z64 to model
data, so the export does not need `make init`, splat or crunch64.
"""
import hashlib
import struct
# ROM offsets taken from pokestadium-us.yaml.
POKEMON_MODELS = 0x920000 # archive of the 215 battle models
BATTLE_DATA = 0x70D3A0 # per-species battle tables, indexed by D_80075BD0
MAIN_ROM = 0x1000 # main code segment ...
MAIN_VRAM = 0x80000400 # ... and where it lands in RAM
PTR_TABLE_VRAM = 0x80075BD0 # D_80075BD0[species - 1] -> offset into BATTLE_DATA
US_MD5 = 'ed1378bc12115f71209a77844965ba50'
class Rom:
def __init__(self, path):
data = bytearray(open(path, 'rb').read())
magic = struct.unpack_from('>I', data, 0)[0]
if magic == 0x37804012: # .v64, byte-swapped pairs
data[0::2], data[1::2] = data[1::2], data[0::2]
elif magic == 0x40123780: # .n64, word-reversed
data = bytearray(b''.join(data[i:i+4][::-1] for i in range(0, len(data), 4)))
elif magic != 0x80371240: # .z64, native big endian
raise ValueError(f'{path}: not an N64 ROM (magic {magic:#010x})')
self.data = bytes(data)
self.md5 = hashlib.md5(self.data).hexdigest()
@property
def is_expected_us(self):
return self.md5 == US_MD5
def u32(self, o):
return struct.unpack_from('>I', self.data, o)[0]
def vram_to_rom(self, vram):
return MAIN_ROM + (vram - MAIN_VRAM)
# ---- archive ---------------------------------------------------------
def archive(self, off):
"""Segments that hold many files start with
u32 tag, u32 0, u32 totalSize, u32 fileCount
followed by fileCount { u32 offset, u32 size, u32 pad[2] } records,
all relative to the start of the segment (tools/unpack_asset.py).
Only the top three bytes of the first word are reliably zero -- the
model archive puts a nonzero value in the low byte."""
if (self.u32(off) & 0xFFFFFF00) != 0 or self.u32(off + 4) != 0:
return [self.data[off:]]
count = self.u32(off + 12)
if not 0 < count < 4096:
return [self.data[off:]]
out = []
for i in range(count):
rec = off + 0x10 + i * 0x10
start, size = self.u32(rec), self.u32(rec + 4)
out.append(self.data[off + start: off + start + size])
return out
# ------------------------------------------------------------- decompression
def yay0_decompress(src):
"""Nintendo Yay0. Header: magic, decompressed size, link offset, chunk
offset; then a bitstream where a 1 copies one literal byte and a 0 pulls a
(distance, length) pair from the link table."""
if src[:4] != b'Yay0':
raise ValueError('not Yay0')
size, link_off, chunk_off = struct.unpack_from('>3I', src, 4)
out = bytearray(size)
mask_p, link_p, chunk_p, pos = 0x10, link_off, chunk_off, 0
mask, bits = 0, 0
while pos < size:
if bits == 0:
mask = struct.unpack_from('>I', src, mask_p)[0]
mask_p += 4
bits = 32
if mask & 0x80000000:
out[pos] = src[chunk_p]
chunk_p += 1
pos += 1
else:
link = struct.unpack_from('>H', src, link_p)[0]
link_p += 2
dist = link & 0x0FFF
count = link >> 12
if count == 0:
count = src[chunk_p] + 0x12
chunk_p += 1
else:
count += 2
copy = pos - dist - 1
for _ in range(count): # overlapping runs are legal
out[pos] = out[copy]
pos += 1
copy += 1
mask = (mask << 1) & 0xFFFFFFFF
bits -= 1
return bytes(out)
def decompress(blob):
"""Unwrap whatever container an asset arrived in."""
if blob[:8] == b'PERS-SZP':
header = struct.unpack_from('>I', blob, 8)[0]
return yay0_decompress(blob[header:])
if blob[:4] == b'Yay0':
return yay0_decompress(blob)
return blob
def pokemon_models(rom):
"""Returns the decompressed model fragments, indexed by file number."""
return [decompress(b) for b in rom.archive(POKEMON_MODELS)]
+244 -12
View File
@@ -204,8 +204,19 @@ T.check(not pinnedIds["battleLayout"],
T.eq(layoutGame.save.options.battleLayout, "og",
"and a save that had WIDE is set to OG -- the only layout the shot composes in")
-- the STADIUM rung is still a staged fight, so it pins the layout exactly
-- as 2D-3D does: what changes on that rung is what stands on the cells, not
-- where the cells are or what screen they are composed for
Battles.setting:setValue("stadium", layoutGame)
T.eq(Battles.enabled(), true, "STADIUM stages the fight like 2D-3D does")
layoutGame.save.options.battleLayout = "wide"
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
"and pins BATTLE LAYOUT to OG the same way")
-- switching 3D-BTL off hands the row straight back, WIDE and all
Battles.setting:setIndex(2, layoutGame)
Battles.setting:setValue(false, layoutGame)
T.eq(Battles.enabled(), false, "3D-BTL off")
local handedBack = Runtime.call("ui.options.rows", function(_, r) return r end,
layoutGame,
@@ -235,7 +246,7 @@ T.eq(layoutGame.save.options.battleLayout, "wide",
-- switch the row back on and the pin comes back with it, FULL or no FULL.
-- (Arriving at FULL for real runs applyFull, which switches the row on -- so
-- in the game the pin still follows the preset, by way of the row.)
Battles.setting:setIndex(1, layoutGame)
Battles.setting:setValue(true, layoutGame)
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
@@ -366,7 +377,7 @@ T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
-- down than the player left it.
do
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
Battles.setting:setIndex(2, menuGame) -- staged battles off
Battles.setting:setValue(false, menuGame) -- staged battles off
menuGame.save.options.battleLayout = "wide"
Pipelines.setLevel("voxel", 2)
local layoutMenu = OptionsMenu.new(menuGame)
@@ -408,9 +419,10 @@ T.eq(grid.value(), "OFF", "the grid starts off")
T.eq(curve.label, "V-CURVE", "the curve row carries its label")
T.eq(curve.value(), "OFF", "the curve starts off")
T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label")
T.eq(battles.value(), "ON",
"overworld battles are on by default -- the mode's headline is the world "
.. "in 3D, and a battle is where the player spends half the game")
T.eq(battles.value(), "2D-3D",
"overworld battles are on by default, on the rung that stands the game's "
.. "own pics on the map -- the mode's headline is the world in 3D, and a "
.. "battle is where the player spends half the game")
T.eq(backRow.label, "BACK SPRITES", "the back-pic row carries its label")
T.eq(backRow.value(), "OFF",
"and is off by default -- what the mode advertises is BOTH mons out on the "
@@ -1124,11 +1136,131 @@ Game.keypressed(keyGame, "7")
T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE")
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.setting:get(), true, "3D-BTL starts on")
T.eq(Battles.setting:get(), true, "3D-BTL starts on 2D-3D")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), false, "8 toggles overworld battles off")
T.eq(Battles.setting:get(), "stadium",
"8 steps to STADIUM A -- one press off the default is the more elaborate "
.. "staged fight, not the absent one")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and back on")
T.eq(Battles.setting:get(), "stadiumB",
"again and it is STADIUM B, the discs")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), false, "again and overworld battles are off")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D")
-- ------- the eyes blink once a loop, not six times a second
--
-- A texture animation is sampled at the SKELETAL animation's frame and HOLDS
-- its last entry past the end of its own stream, which is what the game's own
-- sampler does. Wrapping on the stream's length instead plays it over and
-- over: Rattata's standby loop is 40 frames and its blink is 5, so that came
-- out as six blinks a second.
--
-- Driven through a stub rather than a real rig, because building one needs
-- meshes and there is no graphics context here -- and the rule under test is
-- pure index arithmetic that does not care.
;(function()
local Rig = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumRig")
-- one prim on channel 0, whose stream values 6/7/8 map to textures 60/70/80
local prim = { tex = 1, texAnim = 0, texMap = { [6] = 60, [7] = 70,
[8] = 80 } }
local model = {
prims = { prim },
textures = { [1] = { w = 1, h = 1 }, [60] = { w = 1, h = 1 },
[70] = { w = 1, h = 1 }, [80] = { w = 1, h = 1 } },
-- Rattata's actual blink: open, half, closed, half, open
auxAnims = { { frames = 5, loopStart = 0, channels = { { 6, 8, 7, 8, 6 } } } },
}
local part = { prim = prim }
local stub = setmetatable({ model = model, parts = { part } }, Rig)
-- StadiumPack.image wants a real texture; what is asserted here is WHICH
-- index was chosen, so record it instead
local Pack = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack")
local realImage = Pack.image
local picked
Pack.image = function(_, index) picked = index return nil end
local function at(frame)
stub.frameAt = frame
stub:textures(1)
return picked
end
T.eq(at(0), 60, "frame 0 of the blink is the open eye")
T.eq(at(1), 80, "frame 1 is half closed")
T.eq(at(2), 70, "frame 2 is shut")
T.eq(at(4), 60, "and frame 4 is open again -- one blink, five frames")
-- the whole point: frames 5..39 of the forty-frame idle are NOT a second
-- blink, they are the eye staying open
T.eq(at(5), 60, "frame 5, past the end of the blink, HOLDS the open eye")
T.eq(at(20), 60, "and so does frame 20")
T.eq(at(39), 60, "and frame 39, the last of the idle loop")
Pack.image = realImage
end)()
-- ------- the collapse waits for the HP bar
--
-- onFaint fires the moment HP reaches zero, but the engine queues the visible
-- collapse behind the move animation and the bar drain -- seconds later. The
-- model has to wait for the same thing, or a Pokemon lies down while its own
-- health is still draining above it. `shownHP` is the engine's own bar
-- position, so this is the bar and not a guess at how long it takes.
;(function()
local Stad = run.loader.exports.DRAMATIC_SHAPE.lib.require("Stadium")
local ready, due = Stad._faintReady, Stad._faintStillDue
T.eq(ready({ shownHP = 19, mon = { hp = 0 } }), false,
"a battler at 0 HP whose bar still reads 19 is NOT ready to collapse")
T.eq(ready({ shownHP = 1, mon = { hp = 0 } }), false,
"nor at one point left on the bar")
T.eq(ready({ shownHP = 0, mon = { hp = 0 } }), true,
"and is the moment the bar reaches zero")
T.eq(ready({ mon = { hp = 0 } }), true,
"a battler with no bar to drain collapses at once, rather than never")
T.eq(ready(nil), false, "and a battler that is gone is not ready")
T.eq(due({ faintQueued = true, mon = { hp = 0 } }), true,
"a queued faint at 0 HP is still owed")
T.eq(due({ faintQueued = true, mon = { hp = 12 } }), false,
"one that has been healed since is not -- the debt is dropped, not paid "
.. "late at whoever is standing there")
T.eq(due({ mon = { hp = 0 } }), false, "and an unqueued battler owes nothing")
end)()
-- ------- and the two STADIUM rungs are SKIPPED when the models are not there
--
-- The mod ships no Pokemon Stadium data, so on a machine whose owner has not
-- supplied that ROM the row has two stops rather than four. Checked by gating
-- them off by hand rather than by hiding the packs, because what is being
-- tested is the ladder's behaviour and not the installer's.
;(function()
local gate = Battles.setting.gate
Battles.setting:setGate(function(value)
return value ~= "stadium" and value ~= "stadiumB"
end)
T.eq(Battles.setting:rungs(), 2,
"with no models built the 3D-BTL row offers two rungs, not four")
Battles.setting:setValue(true, Game)
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), false,
"and 8 steps straight past both STADIUM rungs to OFF")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and back to 2D-3D")
-- a save that CHOSE stadium before the ROM went missing reads as the
-- default, rather than as a mode with nothing behind it -- and the stored
-- value is left alone, so putting the ROM back restores the choice
Battles.setting.index = 2
T.eq(Battles.setting:get(), true,
"a stored STADIUM with no models behind it reads as 2D-3D")
Battles.setting:setGate(gate)
T.eq(Battles.setting:get(), "stadium",
"and comes back the moment the models do")
Battles.setting:setValue(true, Game)
end)()
-- ------- SELECT makes the same step the 3 key does
--
@@ -2579,7 +2711,7 @@ T.eq(Battles.backPinned(), false, "so nothing is pinned to the menu")
local backGame = { save = { options = { modOptions = {} } },
mods = { modOptions = {} } }
Battles.setting:setIndex(1, backGame) -- 3D-BTL on
Battles.setting:setValue(true, backGame) -- 3D-BTL on 2D-3D
Battles.backSetting:setIndex(2, backGame) -- BACK SPRITES on
T.eq(Battles.backPinned(), true, "switched on, the back pic is pinned")
T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battleBack, true,
@@ -2590,7 +2722,7 @@ T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battles, true,
-- and it means nothing at all with staged battles off: there is no staged
-- shot for a back pic to be pinned in front of, and the engine's own battle
-- screen already draws exactly this
Battles.setting:setIndex(2, backGame)
Battles.setting:setValue(false, backGame)
T.eq(Battles.backPinned(), false,
"with 3D-BTL off the setting decides nothing, whatever it is left at")
T.eq(Battles.backSetting:get(), true, "without being rewritten underneath")
@@ -2606,7 +2738,7 @@ T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
T.check(not offIds["DRAMATIC_SHAPE:battleBack"],
"but BACK SPRITES is off the menu while there is no staged fight to be about")
Battles.setting:setIndex(1, backGame)
Battles.setting:setValue(true, backGame)
local onRows = Runtime.call("ui.options.rows", function(_, r) return r end,
backGame, { { id = "tilt" } })
local onAt = {}
@@ -4929,6 +5061,106 @@ end)()
T.eq(Gun.visible(), false, "and it is not drawn with the mode off")
end)()
-- ------- STADIUM: the .dsm packs, and the rig that reads them
--
-- Wrapped in its own scope for the same reason the horde block above is:
-- the main chunk is at Lua's 200-local ceiling, so a new top-level local
-- would refuse to compile.
--
-- What this is really guarding is the FORMAT SEAM. tools/stadium_pack.py
-- writes those files and lib/StadiumPack.lua reads them, and the two agree
-- only by having been written to agree -- there is no schema between them.
-- A field inserted on one side and not the other slides every byte after it
-- and produces no error at all: the models load, the numbers are garbage,
-- and every Pokemon is silently scaled to nothing. That is exactly what
-- happened once during development, and the assertion that caught it is the
-- one below -- walk the bind pose with the REAL rig code and check it
-- against the header the packer wrote, which cannot agree by accident.
;(function()
local Pack = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack")
local Rig = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumRig")
-- The packs are generated (tools/stadium_pack.py) and a checkout without
-- them is a legitimate state -- the mode declines per Pokemon. So the whole
-- block is skipped rather than failed when they are not there.
if not Pack.available(25) then
T.check(true, "stadium packs are not installed -- pack assertions skipped")
return
end
local pikachu = Pack.load(25)
T.check(pikachu ~= nil, "a stadium pack loads")
T.eq(pikachu.species, 25, "and knows which species it is")
T.eq(pikachu.boneCount, 37, "Pikachu's rig is 37 bones, as the extract reports")
T.check(pikachu.rootScale > 0.09 and pikachu.rootScale < 0.11,
"the model_root scale came through as the 0.1 the geo layout sets")
-- the battle system's own slot table: idle is animation 0 for all 151
-- species (manifest.json's animationSlots calls that one `code` evidence)
T.eq(pikachu.ctx[Pack.SLOT.idle], 0, "the idle slot resolves to animation 0")
T.check(pikachu.ctx[Pack.SLOT.faint] ~= Pack.NONE, "and the faint slot resolves")
T.check(pikachu.ctx[Pack.SLOT.entrance] ~= Pack.NONE, "and the entrance slot")
-- the move table is the Gen 1 move id, which is the engine's own `index`
T.eq(#pikachu.moveAnim, Pack.N_MOVES, "every move id has a row")
T.check(pikachu.moveAnim[85] ~= Pack.NONE,
"and THUNDERBOLT (move 85) names an animation Pikachu has")
-- animations decode lazily; asking for one is what builds its tracks
local tracks = Pack.tracks(pikachu, 1)
T.check(type(tracks) == "table", "an animation's tracks decode on demand")
local animated = 0
for b = 1, pikachu.boneCount do if tracks[b] then animated = animated + 1 end end
T.check(animated > 0 and animated <= pikachu.boneCount,
"and move a sane number of the rig's bones")
-- THE SEAM. Walk the bind pose with the shipping rig code and measure it
-- the way tools/stadium_pack.py measured it. The packer's own answer was
-- checked against the reference glTF export on all 151 species, so
-- agreement here means the byte layout, the bone tree, the rotation basis
-- and the two-chain scale split all survived the trip into Lua.
local function bindExtent(model)
local rig = setmetatable({
model = model, pivotM = {}, drawM = {}, accX = {}, accY = {}, accZ = {},
parts = {},
}, Rig)
rig:pose(nil, 0, false)
local drw = rig.drawM
local lo, hi = math.huge, -math.huge
for _, prim in ipairs(model.prims) do
for k = 1, prim.vertCount do
local o = (prim.bone[k] - 1) * 12
local y = drw[o + 5] * prim.px[k] + drw[o + 6] * prim.py[k]
+ drw[o + 7] * prim.pz[k] + drw[o + 8]
y = y * model.rootScale
if y < lo then lo = y end
if y > hi then hi = y end
end
end
return hi - lo, lo
end
for _, dex in ipairs({ 25, 6, 95, 143 }) do
local model = Pack.load(dex)
if model then
local h, f = bindExtent(model)
-- a tenth of a game unit of slack: bone translations are stored as
-- integers and the packer measured in doubles
T.check(math.abs(h - model.height) < 0.5,
("species %d: the rig's bind pose is the height the pack recorded "
.. "(walked %.2f, header %.2f)"):format(dex, h, model.height))
T.check(math.abs(f - model.floor) < 0.5,
("species %d: and its feet are where the pack put them"):format(dex))
end
end
-- the three species whose standby loop is corrupt in the source extraction
-- are marked to hold their bind pose instead of coming apart
T.eq(Pack.load(126).staticPose, true,
"Magmar is held at its bind pose -- its source animations are broken")
T.eq(pikachu.staticPose, false, "and a species with good data is not")
end)()
Pipelines.reset()
run.release()
+149
View File
@@ -0,0 +1,149 @@
-- What the model build COSTS -- the numbers that decide whether it can run
-- on a phone.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_budget_test.lua [--rom=PATH]
--
-- Run from the PROJECT ROOT.
--
-- ------- why this is a test and not a note
--
-- The extraction is pure Lua on purpose -- no FFI, no native helper, no
-- second process -- specifically so it can run everywhere LOVE does, phones
-- included. That claim is about MEMORY as much as about which functions
-- exist: a desktop will not notice a transient peak that would have a mobile
-- OS kill the process, and the peak is not visible by reading the code.
--
-- So this measures it, and fails if the two things that must stay bounded
-- stop being bounded:
--
-- the WORKING SET must not grow with the number of species done, or the
-- build gets heavier the longer it runs and dies somewhere in the 140s
--
-- the PEAK must stay under a budget a phone can be expected to have
-- spare, on top of a game that is already running
--
-- The ROM itself is most of the floor and cannot be avoided -- the archive is
-- addressed by absolute offset -- so it is reported separately from the
-- per-species work built on top of it.
local args = {}
for _, a in ipairs({ ... }) do
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
if k then args[k] = v end
end
local MOD = "mods/DramaticShapeVoxelMod"
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
-- How much headroom the whole build may take on top of the ROM, in MB. Sized
-- against the smallest thing this is expected to run on rather than against
-- what is convenient: a mid-range phone from several years ago, with a game
-- already resident.
local PEAK_BUDGET_MB = 220
-- How much the working set may drift between the first ten species and the
-- last ten, in MB. Not zero -- species differ in size by a factor of ten, and
-- the collector is not obliged to run on any particular schedule -- but a
-- genuine leak shows up here as tens of megabytes.
local DRIFT_BUDGET_MB = 24
local loaded = {}
local V = {}
function V.require(name)
if loaded[name] == nil then
local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
loaded[name] = chunk(V)
end
return loaded[name]
end
V.mod = { log = { warn = function() end, info = function() end } }
local StadiumRom = V.require("StadiumRom")
local StadiumBuild = V.require("StadiumBuild")
local function mb()
return collectgarbage("count") / 1024
end
-- A single collectgarbage() is not guaranteed to finish a cycle, and a
-- half-finished one reads tens of megabytes high -- which showed up here as
-- "settled" figures BELOW the baseline they were measured against. Run it
-- until the number stops moving.
local function settle()
local last = mb()
for _ = 1, 8 do
collectgarbage("collect")
local now = mb()
if now >= last - 0.05 then return now end
last = now
end
return mb()
end
-- The baseline is taken BEFORE the ROM is read, or the file is already on the
-- heap when it is measured and the ROM appears to cost nothing.
local base = settle()
local fp = io.open(ROM, "rb")
if not fp then
io.stderr:write("no ROM at " .. ROM .. "\n")
os.exit(2)
end
local romBytes = fp:read("*a")
fp:close()
local rom = assert(StadiumRom.open(romBytes))
local withRom = settle()
local peak, settled = withRom, {}
local bytes = 0
local t0 = os.clock()
for fileno = 0, StadiumRom.N_POKEMON - 1 do
local res = assert(StadiumBuild.species(rom, fileno))
bytes = bytes + #res.bytes
-- the live peak, before the collector has been asked for anything: this is
-- what the allocator actually had to find
local live = mb()
if live > peak then peak = live end
-- and the settled working set, which is what must not creep
settled[fileno + 1] = settle()
end
local dt = os.clock() - t0
local function avg(from, to)
local sum = 0
for i = from, to do sum = sum + settled[i] end
return sum / (to - from + 1)
end
local first = avg(1, 10)
local last = avg(#settled - 9, #settled)
local drift = last - first
io.write(("ROM %6.1f MB (the file, held for the whole build)\n")
:format(withRom - base))
io.write(("peak working set %6.1f MB (over the ROM)\n"):format(peak - withRom))
io.write(("settled, first 10 %6.1f MB\n"):format(first - withRom))
io.write(("settled, last 10 %6.1f MB\n"):format(last - withRom))
io.write(("drift %+6.1f MB (budget %.0f)\n")
:format(drift, DRIFT_BUDGET_MB))
io.write(("total peak %6.1f MB (budget %.0f)\n")
:format(peak, PEAK_BUDGET_MB))
io.write(("output %6.1f MB in %.1fs (%.0f ms a species)\n")
:format(bytes / 1e6, dt, dt * 1000 / StadiumRom.N_POKEMON))
local fail = false
if peak > PEAK_BUDGET_MB then
io.write(("FAIL peak %.1f MB is over the %.0f MB budget\n")
:format(peak, PEAK_BUDGET_MB))
fail = true
end
if drift > DRIFT_BUDGET_MB then
io.write(("FAIL the working set grew %.1f MB across the run -- something is "
.. "being retained per species\n"):format(drift))
fail = true
end
if fail then os.exit(1) end
io.write("PASS -- bounded working set, peak within budget\n")
+128
View File
@@ -0,0 +1,128 @@
-- The Lua ROM extractor, against the Python packer that is its oracle.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_extract_test.lua \
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR]
--
-- Run from the PROJECT ROOT. Defaults: the ROM under
-- model_extract/baseroms/, the oracle in assets/stadium (whatever
-- tools/stadium_pack.py last wrote there).
--
-- ------- what this is for
--
-- lib/StadiumRom, StadiumFragment, StadiumFx and StadiumBuild are a port of
-- roughly fifteen hundred lines of dense numeric Python -- an F3DEX2
-- interpreter, six texture codecs, a bit-packed animation sampler, a noise
-- generator and a binary writer. Reading a port of that twice does not
-- establish that it is right. Producing the same thirty-four megabytes,
-- byte for byte, does.
--
-- It is also the guard on the two of them DRIFTING. The packer and the
-- extractor have to keep agreeing about the format forever, and a change to
-- one that is not made to the other shows up here as a differing offset
-- rather than as a Pokemon that renders as noise three months later.
--
-- Headless: no LOVE, no graphics. Everything these four modules do is
-- arithmetic on strings.
local args = {}
for _, a in ipairs({ ... }) do
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
end
local MOD = "mods/DramaticShapeVoxelMod"
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
local ORACLE = args.oracle or (MOD .. "/assets/stadium")
-- ------- the mod namespace, enough of it to load four modules
local loaded = {}
local V = {}
function V.require(name)
if loaded[name] == nil then
local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
loaded[name] = chunk(V)
end
return loaded[name]
end
V.mod = { log = { warn = function() end, info = function() end } }
local StadiumRom = V.require("StadiumRom")
local StadiumBuild = V.require("StadiumBuild")
-- ------- run
local function readFile(path)
local fp = io.open(path, "rb")
if not fp then return nil end
local data = fp:read("*a")
fp:close()
return data
end
local romBytes = readFile(ROM)
if not romBytes then
io.stderr:write("no ROM at " .. ROM .. "\n")
os.exit(2)
end
local rom, err = StadiumRom.open(romBytes)
if not rom then
io.stderr:write("could not open ROM: " .. tostring(err) .. "\n")
os.exit(2)
end
local only = nil
if args.only then
only = {}
for n in args.only:gmatch("%d+") do only[tonumber(n)] = true end
end
local checked, matched, missing, failed = 0, 0, 0, 0
local firstBad = nil
local t0 = os.clock()
for fileno = 0, StadiumRom.N_POKEMON - 1 do
local ok, res = pcall(StadiumBuild.species, rom, fileno)
if not ok or not res then
-- res carries the message on the error path
io.write(("file %d: EXTRACT FAILED: %s\n"):format(fileno, tostring(res)))
failed = failed + 1
elseif not only or only[res.species] then
checked = checked + 1
if args.out then
local fp = io.open(("%s/%03d.dsm"):format(args.out, res.species), "wb")
if fp then fp:write(res.bytes) fp:close() end
end
local want = readFile(("%s/%03d.dsm"):format(ORACLE, res.species))
if not want then
missing = missing + 1
elseif want == res.bytes then
matched = matched + 1
else
-- where, exactly: the first differing byte localises a format mistake
-- far faster than "the file is wrong" does
local at = nil
local n = math.min(#want, #res.bytes)
for i = 1, n do
if want:sub(i, i) ~= res.bytes:sub(i, i) then at = i break end
end
io.write(("%03d.dsm DIFFERS: %d bytes vs %d, first at %s\n")
:format(res.species, #res.bytes, #want,
at and ("offset " .. at) or "(one is a prefix)"))
if not firstBad then firstBad = res.species end
end
end
end
io.write(("\n%d checked, %d identical, %d differ, %d oracle files missing, "
.. "%d extractions failed (%.1fs)\n")
:format(checked, matched, checked - matched - missing, missing,
failed, os.clock() - t0))
if matched == checked and failed == 0 and missing == 0 then
io.write("PASS -- the Lua extractor reproduces the packer exactly\n")
os.exit(0)
end
io.write("FAIL\n")
os.exit(1)
+115
View File
@@ -0,0 +1,115 @@
-- Probe: read the .dsm packs back with the mod's OWN reader and print what
-- it made of them, so the Lua side can be diffed against the packer's
-- --report output. Headless -- no love.graphics, no meshes.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_pack_probe.lua [dex ...]
--
-- Run from the repository root.
package.path = "./?.lua;./?/init.lua;" .. package.path
local ROOT = "mods/DramaticShapeVoxelMod"
-- the mod namespace lib/ modules expect (see main.lua), with just enough of
-- it for the two files under test
local V = {}
local modules = {}
V.mod = {
log = {
warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end,
info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end,
},
read = function(_, rel)
local fp = io.open(ROOT .. "/" .. rel, "rb")
if not fp then return nil end
local bytes = fp:read("*a")
fp:close()
return bytes
end,
}
function V.require(name)
if modules[name] then return modules[name] end
local chunk = assert(loadfile(ROOT .. "/lib/" .. name .. ".lua"))
modules[name] = chunk(V)
return modules[name]
end
-- StadiumRig pulls in Voxel3D, which needs love; the pose walk itself does
-- not, so the require is stubbed out to the one field it reads.
modules.Voxel3D = { FORMAT = {} }
local Pack = V.require("StadiumPack")
local Rig = V.require("StadiumRig")
local want = {}
for i = 1, #arg do want[#want + 1] = tonumber(arg[i]) end
if #want == 0 then want = { 25, 6, 130, 95, 50, 10, 143, 92, 41, 73 } end
print(("%-4s %-6s %6s %6s %6s %8s %8s %8s %8s")
:format("dex", "bones", "prims", "anims", "tex", "root", "height",
"floor", "radius"))
for _, dex in ipairs(want) do
local m = Pack.load(dex)
if not m then
print(("%-4d -- did not load"):format(dex))
else
print(("%-4d %-6d %6d %6d %6d %8.4f %8.2f %8.2f %8.2f")
:format(dex, m.boneCount, m.primCount, m.animCount, m.texCount,
m.rootScale, m.height, m.floor, m.radius))
-- the bind pose, walked by the REAL rig code on a bare instance (no
-- meshes, so no graphics context needed), measured the same way
-- tools/stadium_pack.py measures it. Agreement here means the byte
-- format, the bone tree, the rotation basis and the two-chain scale
-- split all survived the trip into Lua.
local rig = setmetatable({
model = m, pivotM = {}, drawM = {}, accX = {}, accY = {}, accZ = {},
parts = {},
}, Rig)
local function boxAt(anim, frame, wrap)
rig:pose(anim, frame, wrap)
local drw = rig.drawM
local lo, hi = math.huge, -math.huge
for _, prim in ipairs(m.prims) do
for k = 1, prim.vertCount do
local o = (prim.bone[k] - 1) * 12
local x, y, z = prim.px[k], prim.py[k], prim.pz[k]
local wy = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z
+ drw[o + 8]
wy = wy * m.rootScale
if wy < lo then lo = wy end
if wy > hi then hi = wy end
end
end
return hi - lo, lo
end
local h, f = boxAt(nil, 0, false)
print((" bind pose walked in Lua: height %8.2f floor %8.2f "
.. "(header says %8.2f / %8.2f)%s")
:format(h, f, m.height, m.floor,
(math.abs(h - m.height) < 0.5
and math.abs(f - m.floor) < 0.5) and " OK" or " MISMATCH"))
-- the animation tables, decoded lazily like the game does it
local idle = m.ctx[Pack.SLOT.idle]
local names = {}
for i, a in ipairs(m.anims) do names[i] = a.name end
print((" anims: %s"):format(table.concat(names, " ")))
print((" idle slot -> %s, entrance -> %s, faint -> %s")
:format(tostring(idle), tostring(m.ctx[Pack.SLOT.entrance]),
tostring(m.ctx[Pack.SLOT.faint])))
local tracks = Pack.tracks(m, (idle or 0) + 1)
local animated = 0
for b = 1, m.boneCount do if tracks and tracks[b] then animated = animated + 1 end end
print((" idle: %d frames, %d/%d bones animated")
:format(m.anims[(idle or 0) + 1].frames, animated, m.boneCount))
-- the POSED extent, which is what actually gets drawn. Compare against
-- tools/stadium_pack.py's anim_sample, which was checked frame for
-- frame against the reference glTF export.
for _, fr in ipairs({ 0, 1, 5 }) do
local ah, af = boxAt((idle or 0) + 1, fr, true)
print((" idle frame %d: y %8.2f .. %8.2f"):format(fr, af, af + ah))
end
end
end
+294
View File
@@ -0,0 +1,294 @@
-- Driver: screenshot a STADIUM battle -- the 3D-BTL row's third rung, where
-- the two Pokemon are the Pokemon Stadium battle models rather than the
-- game's own pics stood up on their tiles.
--
-- What has to be looked at is a SPREAD OF SPECIES, because almost everything
-- that can go wrong here is per-model: how big it comes out (the size ladder
-- compresses a sixteenfold spread of authored heights -- see StadiumMon),
-- whether its feet are on the ground (the floor rule has to tell a hovering
-- Zubat from a Tentacruel centred on its own origin), whether the eyes
-- animate, and whether the species carries generated flame prims. So the
-- list below is deliberately awkward: the smallest and the largest in the
-- set, a hoverer, a floater, and the two species whose idle animation is
-- known to be wrong in the source data.
--
-- SHOT_DIR=.scratchpad DS_STADIUM_DEBUG=1 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_shots.lua love .
--
-- SHOT_DIR must already exist: the capture writes with io.open, which does
-- not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Pack = lib.require("StadiumPack")
local Install = lib.require("StadiumInstall")
game.save.player.name = "RED"
-- foe, and the player's own, chosen to put two different problems in one
-- frame wherever possible
local CASES = {
{ "PIKACHU", "CHARIZARD" }, -- the reference pair; Charizard has flame
{ "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest
{ "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it
{ "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set
{ "EXEGGUTOR", "MAGMAR" }, -- both idle animations are wrong at source
{ "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set
}
-- ONTO THE MAP FIRST, before anything below waits on anything.
--
-- The models are built out of the player's own ROM on first run, on a
-- loading screen, and that screen is pushed from the mod's update hook on
-- the first frame THE OVERWORLD IS THE TOP STATE (StadiumScreen.maybePush).
-- A driver that waits for the build before it has got the player onto a map
-- waits forever: the condition that starts the build is the very thing the
-- waiting is postponing.
-- DS_MAP puts the fight somewhere else. It exists for STADIUM B, whose
-- whole claim is that it carries its stage: the shot has to be checked in a
-- CAVE and in a SHOP as well as on a route, both because those are the
-- places the map-staged rungs decline, and because the rung is supposed to
-- take that place's own sky and light with it.
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local MX = tonumber(os.getenv("DS_X") or "") or 5
local MY = tonumber(os.getenv("DS_Y") or "") or 8
U.teleport(game, MAP, MX, MY, "down")
-- Now wait it out, and say how long it took -- that number is what decides
-- whether the loading screen is acceptable rather than merely correct.
local waited, shotLoad = 0, false
while (Install.pending() or Install.status.state == "building")
and waited < 5400 do
if not shotLoad and Install.status.state == "building"
and (Install.status.done or 0) > 20 then
U.shot(game, ("%s/00_loading.png"):format(DIR))
shotLoad = true
end
U.wait(10)
waited = waited + 10
end
if waited > 0 then
U.log(("waited %d frames (%.1fs) for the model build -- %s")
:format(waited, waited / 60, tostring(Install.status.state)))
if Install.status.error then
U.log("build error: " .. tostring(Install.status.error))
end
end
U.log(("rom present: %s, packs ready: %s, stadium available: %s")
:format(tostring(Install.romPresent()), tostring(Install.ready()),
tostring(Install.available())))
-- the rung under test, and the back pic OFF: BACK SPRITES would keep the
-- player's own side on the menu, which is exactly the half of the shot
-- this driver exists to look at
local RUNGS = { cards = true, a = "stadium", b = "stadiumB" }
local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium"
Battles.setting:setValue(rung, game)
Battles.backSetting:setValue(false, game)
-- and PIN THE CLOCK. The hour multiplies everything in the shot -- the sky,
-- the tint, the weight of the shadows -- so a driver left on CYCLE
-- photographs a different scene every run, and two runs of it cannot be
-- compared. DS_TOD overrides for a deliberate look at dusk or night.
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
U.log(("3D-BTL = %s, stadium enabled = %s, discs = %s")
:format(tostring(Battles.setting:get()), tostring(Stadium.enabled()),
tostring(Stadium.discs())))
-- did the models actually get built? one line rather than 151 silent
-- declines
local have, missing = 0, {}
for dex = 1, 151 do
if Pack.available(dex) then have = have + 1
elseif #missing < 6 then missing[#missing + 1] = dex end
end
U.log(("stadium packs present: %d/151%s"):format(
have, #missing > 0 and (" (missing " .. table.concat(missing, ",")
.. "...)") or ""))
-- let the neighbourhood's meshes land before the first fight, so the
-- opening frame is not the flat fallback
U.wait(90)
-- ------- DS_BLINK: how often does the eye actually change?
--
-- The texture animation rides the SKELETAL animation's frame and clamps
-- past the end of its own stream (StadiumRig.textures). Get either wrong
-- and a blink becomes a twitch: Rattata's standby loop is forty frames and
-- its blink is five, so wrapping on the blink's own length plays it six
-- times a second.
--
-- Counted rather than looked at, because "too fast" is a RATE and a
-- screenshot has no rate in it. This walks the idle loop frame by frame and
-- reports how many times the eye texture changes per second.
if os.getenv("DS_BLINK") then
local Pack = lib.require("StadiumPack")
local Rig = lib.require("StadiumRig")
for _, dex in ipairs({ 19, 4, 1, 25 }) do
local model = Pack.load(dex)
local rig = model and Rig.new(model)
if rig then
local idle = model.ctx[Pack.SLOT.idle]
local anim = (idle ~= Pack.NONE) and (idle + 1) or nil
local rec = anim and model.anims[anim]
-- which prim carries the eye, and what it shows each frame
local seen, changes, last = {}, 0, nil
local SECONDS = 4
for f = 0, SECONDS * Pack.FPS - 1 do
rig:pose(anim, f, true)
rig:textures(rec and rec.aux or nil)
local key = {}
for i, part in ipairs(rig.parts) do
if part.prim.texAnim and part.prim.texAnim >= 0 then
key[#key + 1] = i .. ":" .. tostring(part.texture)
end
end
key = table.concat(key, ",")
if last and key ~= last then changes = changes + 1 end
last = key
seen[key] = true
end
local looks = 0
for _ in pairs(seen) do looks = looks + 1 end
local period = rec and (rec.frames / Pack.FPS) or 0
U.log(("BLINK dex %3d: idle %s frames (%.2fs), aux %s frames -- "
.. "%d texture changes in %ds, %d distinct looks; the idle "
.. "loop comes round every %.2fs, so that is %.1f changes a "
.. "cycle")
:format(dex, tostring(rec and rec.frames), period,
tostring(rec and rec.aux
and model.auxAnims[rec.aux].frames),
changes, SECONDS, looks, period,
period > 0 and changes / (SECONDS / period) or 0))
rig:release()
end
end
U.log("done -- " .. DIR)
return
end
-- ------- DS_FAINT: does the collapse wait for the bar?
--
-- The faint animation must not start until the foe's HP bar has finished
-- emptying: `onFaint` fires the instant HP reaches zero, but the engine
-- queues the visible collapse behind the move animation and the drain, and
-- a Pokemon that lies down while its own health is still draining above it
-- reads as a bug (see Stadium's faintReady).
--
-- A TIMELINE rather than a screenshot, because what is being checked is an
-- ORDER: the frame the bar reaches zero against the frame the animation
-- changes. One printed line per frame settles that; two stills cannot.
if os.getenv("DS_FAINT") then
local Stadium2 = lib.require("Stadium")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
local barZeroAt, animAt, shot = nil, nil, false
for f = 1, 400 do
-- keep advancing the text, as a player would. Without this the queue
-- blocks on the damage message and the HP bar never drains at all --
-- which is a perfectly good demonstration that the collapse now waits,
-- and no demonstration whatever that it eventually arrives.
if f % 20 == 0 then U.tap(game, "a") end
U.wait(1)
local foe = battle.enemy
local shown = foe and foe.shownHP
local anim = Stadium2.animOf and Stadium2.animOf("enemy") or nil
if f % 25 == 0 or (shown and shown <= 0 and not barZeroAt) then
U.log((" f=%3d phase=%s shownHP=%s hp=%s queued=%s anim=%s")
:format(f, tostring(battle.phase), tostring(shown),
tostring(foe and foe.mon and foe.mon.hp),
tostring(foe and foe.faintQueued), tostring(anim)))
end
if shown and shown <= 0 and not barZeroAt then barZeroAt = f end
if anim == "faint" and not animAt then animAt = f end
-- a still from the middle of the drain: the foe must still be standing
if shown and shown > 0 and not shot and barZeroAt == nil
and foe and foe.faintQueued then
U.shot(game, ("%s/faint_1_draining.png"):format(DIR))
shot = true
end
if animAt and f > animAt + 20 then
U.shot(game, ("%s/faint_2_collapsed.png"):format(DIR))
break
end
end
U.log(("FAINT: bar reached zero at frame %s, faint animation began at %s")
:format(tostring(barZeroAt), tostring(animAt)))
if barZeroAt and animAt then
U.log((animAt >= barZeroAt)
and " OK -- the collapse waits for the bar"
or " WRONG -- the collapse ran while the bar was still draining")
end
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
U.log("done -- " .. DIR)
return
end
for i, case in ipairs(CASES) do
local foe, mine = case[1], case[2]
local tag = ("%02d_%s_vs_%s"):format(i, mine:lower(), foe:lower())
game.save.party = { Pokemon.new(game.data, mine, 40) }
local battle = BattleState.newWild(game, foe, 30)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- The wipe, then the intro chatter, and -- the part that matters --
-- far enough past it that the player's own Pokemon has been SENT OUT.
-- Before that the player's side is legitimately the trainer's back
-- sprite, with no Pokemon on the field to model at all; a shot taken
-- there looks exactly like a bug and is not one.
U.wait(70)
-- tap until the FIGHT menu is up, which is the first moment BOTH
-- Pokemon are on the field in their standby loop
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.wait(45)
U.shot(game, ("%s/%s_1_idle.png"):format(DIR, tag))
-- FIGHT -> the first move, which is where the attack animation is
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(22)
U.shot(game, ("%s/%s_2_attack.png"):format(DIR, tag))
U.wait(45)
U.shot(game, ("%s/%s_3_hit.png"):format(DIR, tag))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+572
View File
@@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""Pack the Pokemon Stadium battle models into the mod's own .dsm format.
tools/stadium_pack.py [--rom=PATH] [--out=assets/stadium]
[--only=25,6] [--report]
Reads the ROM directly, through model_extract/pipeline -- the same modules,
in the same order, that lib/StadiumRom.lua and lib/StadiumFragment.lua port to
Lua so the mod can do this itself at runtime with no Python and no checked-in
extract. That parallel is the point of this tool now: it is the ORACLE the
Lua extractor is verified against (tests/stadium_extract_test.lua diffs all
151 files byte for byte), so anything that changes the bytes has to change on
both sides or the suite says so.
The output is one file per species, `NNN.dsm`, holding geometry, the bone
tree, every animation, the textures and the battle system's own slot tables
(which animation each move plays, and which one each battle context asks
for). Nothing here is decoded at runtime beyond byte order: lib/
StadiumPack.lua walks these files straight into arrays.
Textures are stored as RAW RGBA8 rather than PNG, which is why the magic is
DSM3. Two reasons, and the second is the one that decided it. A PNG costs a
decode on the frame a battle starts, where an ImageData over the bytes costs
nothing. And PNG means zlib: Python's deflate and LOVE's are both valid and
need not agree byte for byte, so a packed PNG would make this file impossible
to check the Lua extractor against. Uncompressed pixels are the same pixels
whoever wrote them.
Everything is little-endian, so the reader's byte pairs go (low, high).
header
"DSM3" magic
u16 species, bones, prims, textures, anims, auxAnims
f32 rootScale the model_root's own scale
u8 staticPose 1 = never animate
f32 height, floor, radius the bind pose's extent,
in GAME units after
rootScale -- what the mod
sizes a mon on its tile by
u16 moveAnim[165] move id -> animation
i16 moveAux[165] move id -> texture anim
u16 ctxAnim[20] slot 165..184 -> animation
bones[]
i16 parent (-1 root)
i16 t[3] rest translation
i16 r[3] rest rotation, binary
angles (32768 = pi)
i32 s[3] rest scale, 16.16 fixed
prims[]
u16 texture
u8 cull 1 = cull back faces
u8 blend 1 = additive (fx flipbook)
i16 texAnimChannel (-1 none)
u8 texMapN, then texMapN * (u8 key, u16 texture)
u16 fxN, then fxN * u16 flipbook texture frames
u16 verts, indices
verts * (i16 x, y, z ; i16 u, v at 1/512 ; i8 nx, ny, nz ; u8 bone)
indices * u16
textures[]
u16 w, h ; u32 length ; length RGBA8 bytes (= w * h * 4)
anims[]
u8 name length, then the name
u16 frames, loopStart ; i16 aux (-1 none)
bones * track:
u8 1 if this bone is animated at all, else 0
when animated, nine components in order tx ty tz rx ry rz sx sy sz,
each u8 kind (0 constant, 1 one value a frame)
then 1 or `frames` values -- i16 for t and r, i32 16.16 for s
auxAnims[]
u16 frames, loopStart, channels
channels * (u16 length, then that many u16 texture-table indices)
Stdlib only, like the extraction pipeline it drives.
"""
import math
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MOD = os.path.dirname(HERE)
PIPELINE = os.path.join(MOD, 'model_extract', 'pipeline')
sys.path.insert(0, PIPELINE)
import battle # noqa: E402 (needs PIPELINE on the path first)
import build # noqa: E402
import fragment # noqa: E402
import rom as rom_mod # noqa: E402
# The battle system's fixed context slots, in slot order from 165. Names are
# manifest.json's own (animationSlots); the mod indexes this list by position,
# so the ORDER is the contract and must match lib/StadiumPack.lua's CONTEXT.
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',
]
N_MOVES = 165
NONE16 = 0xFFFF
# How many battle Pokemon the model archive holds, and where the fixed context
# slots start in a species' battle table (entries 0..164 are the moves).
N_POKEMON = 151
CTX_BASE = 165
# --------------------------------------------------------------- bind extent
def quat_basis(r):
"""The game's rotation as a 3x3, rows first (src/F420.c func_8000F730).
Rx*Ry*Rz in row-vector form -- the same basis model_extract/pipeline/
glb.py converts to a quaternion, kept as a matrix here because that is
what the runtime builds too.
"""
sx, cx = math.sin(r[0] / 32768 * math.pi), math.cos(r[0] / 32768 * math.pi)
sy, cy = math.sin(r[1] / 32768 * math.pi), math.cos(r[1] / 32768 * math.pi)
sz, cz = math.sin(r[2] / 32768 * math.pi), math.cos(r[2] / 32768 * math.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))
def mat_mul(a, b):
"""3x4 (rotation rows plus a translation column) times the same."""
out = []
for r in range(3):
row = []
for c in range(3):
row.append(sum(a[r][k] * b[k][c] for k in range(3)))
row.append(sum(a[r][k] * b[k][3] for k in range(3)) + a[r][3])
out.append(tuple(row))
return tuple(out)
def rest_sample(bones):
def sample(i):
b = bones[i]
return b['t'], b['r'], b['s']
return sample
def anim_sample(bones, anim, frame):
"""The bone TRS this animation holds at `frame`, rest where it is silent."""
tracks = anim['tracks']
def component(comps, i, fallback):
if comps is None:
return fallback
c = comps[i]
if isinstance(c, list):
return c[frame % len(c)] if c else fallback
return c
def sample(i):
b = bones[i]
tr = tracks[i] if i < len(tracks) else None
if not tr:
return b['t'], b['r'], b['s']
return ([component(tr.get('t'), k, b['t'][k]) for k in range(3)],
[component(tr.get('r'), k, b['r'][k]) for k in range(3)],
[component(tr.get('s'), k, b['s'][k]) for k in range(3)])
return sample
def bind_matrices(bones, sample=None):
"""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. This is that, and it is the same walk
lib/StadiumRig.lua does per frame.
Two chains, and the distinction is the whole point: `pivot` is the
rotation/translation chain 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 would apply every ancestor's scale twice --
which is exactly the multiplicative propagation glTF has and the game
does not (see model_extract/README.md's two-node export).
"""
sample = sample or rest_sample(bones)
pivot, draw, acc = [], [], []
for i, b in enumerate(bones):
bt, br, bs = sample(i)
p = b['parent']
pa = acc[p] if p >= 0 else (1.0, 1.0, 1.0)
pm = pivot[p] if p >= 0 else ((1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0))
t = [bt[i2] * pa[i2] for i2 in range(3)]
rot = quat_basis(br)
local = tuple((rot[r][0], rot[r][1], rot[r][2], t[r]) for r in range(3))
m = mat_mul(pm, local)
a = tuple(pa[i2] * bs[i2] for i2 in range(3))
acc.append(a)
pivot.append(m)
# scale on the right: the bone's own space, so it cannot reach children
draw.append(tuple((m[r][0] * a[0], m[r][1] * a[1], m[r][2] * a[2],
m[r][3]) for r in range(3)))
return draw
def pose_box(data, mats):
"""The axis-aligned box the whole model occupies under `mats`, in game
units after the model_root scale."""
root = data['rootScale'][0]
lo = [1e30, 1e30, 1e30]
hi = [-1e30, -1e30, -1e30]
for prim in data['prims']:
pos, skin = prim['pos'], prim['skin']
for i in range(len(skin)):
m = mats[skin[i]]
x, y, z = pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]
for a in range(3):
v = (m[a][0] * x + m[a][1] * y + m[a][2] * z + m[a][3]) * root
lo[a] = min(lo[a], v)
hi[a] = max(hi[a], v)
return lo, hi
def stance(data):
"""(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. Two things recommend it. It reproduces the verified
glTF export bit for bit on all 151 species, so it is measuring the same
skeleton the reference implementation agreed with; and it is immune to
the animation quirks a handful of species carry (Exeggutor's idle throws
limbs hundreds of units off the body from frame 1 on, and Magmar's does
something similar) -- 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 -- Zubat, Magnemite and Geodude float above their origin,
Tentacruel, Gastly, Haunter, Weezing and Zapdos hang below it. What the
mod does with that is a placement decision and lives in StadiumMon.
"""
lo, hi = pose_box(data, bind_matrices(data['bones']))
if lo[0] > hi[0]:
return 0.0, 0.0, 0.0
return hi[1] - lo[1], lo[1], max(hi[0] - lo[0], hi[2] - lo[2]) / 2
def idle_is_broken(data, idle):
"""Whether this species' standby loop is corrupt in the source data.
A handful of species 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 the exporter 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, which is the one
animation that is supposed to stay where it is -- 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.
"""
if idle is None:
return False
bones = data['bones']
lo, hi = pose_box(data, bind_matrices(bones))
span = hi[1] - lo[1]
if span <= 0:
return False
worst_h, worst_drift = 1.0, 0.0
for frame in range(0, idle['frames'], 3):
flo, fhi = pose_box(data, bind_matrices(bones,
anim_sample(bones, idle, frame)))
worst_h = max(worst_h, (fhi[1] - flo[1]) / span)
worst_drift = max(worst_drift, abs(flo[1] - lo[1]) / span,
abs(fhi[1] - hi[1]) / span)
return ((worst_h > 2.5 and worst_drift > 1.5)
or worst_drift > 2.0 or worst_h > 3.4)
# --------------------------------------------------------------------- write
class Writer:
def __init__(self):
self.parts = []
def raw(self, b):
self.parts.append(b)
def u8(self, v):
self.parts.append(struct.pack('<B', v & 0xFF))
def u16(self, v):
self.parts.append(struct.pack('<H', v & 0xFFFF))
def i16(self, v):
self.parts.append(struct.pack('<h', clamp(int(v), -32768, 32767)))
def i32(self, v):
self.parts.append(struct.pack('<i', clamp(int(v), -2**31, 2**31 - 1)))
def u32(self, v):
self.parts.append(struct.pack('<I', v))
def f32(self, v):
self.parts.append(struct.pack('<f', v))
def i8(self, v):
self.parts.append(struct.pack('<b', clamp(int(v), -128, 127)))
def bytes(self):
return b''.join(self.parts)
def clamp(v, lo, hi):
return lo if v < lo else (hi if v > hi else v)
def fixed(v):
"""16.16, which holds every bone scale in the set (-31 .. 100) exactly
enough that a rounded one is invisible."""
return clamp(int(round(v * 65536)), -2**31, 2**31 - 1)
def write_track_component(w, values, kind):
"""One component of one bone's t/r/s in one animation.
`values` is the js payload'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.
"""
array = isinstance(values, list)
w.u8(1 if array else 0)
seq = values if array else [values]
if kind == 's':
for v in seq:
w.i32(fixed(v))
else:
for v in seq:
w.i16(int(round(v)))
def context_table(rows, n_anims):
"""Which animation each fixed battle context slot resolves to.
Entries 165 upward of the species' own battle table, in slot order, which
is exactly what CONTEXTS lists. 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.
"""
ctx = [NONE16] * len(CONTEXTS)
for i in range(len(CONTEXTS)):
e = CTX_BASE + i
ai = rows[e][0] if e < len(rows) else None
if ai is not None and ai < n_anims:
ctx[i] = ai
return ctx
def pack(data, species, move_rows, ctx):
w = Writer()
bones, prims = data['bones'], data['prims']
textures, anims, aux = data['textures'], data['anims'], data['auxAnims']
height, floor, radius = stance(data)
idle_index = ctx[CONTEXTS.index('idle')]
idle = anims[idle_index] if idle_index != NONE16 else None
static = idle_is_broken(data, idle)
w.raw(b'DSM3')
for v in (species, len(bones), len(prims), len(textures), len(anims),
len(aux)):
w.u16(v)
w.f32(data['rootScale'][0])
# 1 = hold the bind pose, never play an animation (see idle_is_broken).
# Immediately after rootScale, which is where the format table above says
# it is and where lib/StadiumPack.lua reads it.
w.u8(1 if static else 0)
w.f32(height)
w.f32(floor)
w.f32(radius)
rows = move_rows or []
for m in range(N_MOVES):
row = rows[m] if m < len(rows) else None
w.u16(row[0] if row and row[0] < len(anims) else NONE16)
for m in range(N_MOVES):
row = rows[m] if m < len(rows) else None
w.i16(row[1] if row and 0 <= row[1] < len(aux) else -1)
for v in ctx:
w.u16(v)
for b in bones:
w.i16(b['parent'])
for v in b['t']:
w.i16(round(v))
for v in b['r']:
w.i16(v)
for v in b['s']:
w.i32(fixed(v))
for p in prims:
w.u16(p['tex'])
# the display list's own cull mode: 1024 is G_CULL_BACK
w.u8(1 if p.get('cull') else 0)
w.u8(1 if p.get('blend') == 'add' else 0)
w.i16(p.get('texAnim', -1))
tex_map = p.get('texMap') or {}
w.u8(len(tex_map))
for key, tex in sorted(tex_map.items(), key=lambda kv: int(kv[0])):
w.u8(int(key))
w.u16(tex)
frames = p.get('fxFrames') or []
w.u16(len(frames))
for f in frames:
w.u16(f)
pos, uv, nrm, skin, idx = (p['pos'], p['uv'], p['nrm'], p['skin'],
p['idx'])
n = len(skin)
w.u16(n)
w.u16(len(idx))
for i in range(n):
w.i16(pos[i * 3])
w.i16(pos[i * 3 + 1])
w.i16(pos[i * 3 + 2])
# 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(round(uv[i * 2] * 512))
w.i16(round(uv[i * 2 + 1] * 512))
w.i8(round(nrm[i * 3] * 127))
w.i8(round(nrm[i * 3 + 1] * 127))
w.i8(round(nrm[i * 3 + 2] * 127))
w.u8(skin[i])
for v in idx:
w.u16(v)
for t in textures:
rgba = t['rgba']
w.u16(t['w'])
w.u16(t['h'])
w.u32(len(rgba))
w.raw(rgba)
for a in anims:
name = (a.get('name') or '')[:255].encode('utf8')
w.u8(len(name))
w.raw(name)
w.u16(a['frames'])
w.u16(a.get('loopStart', 0))
w.i16(a.get('aux', -1))
tracks = a['tracks']
for bi in range(len(bones)):
tr = tracks[bi] if bi < len(tracks) else None
if not tr:
w.u8(0)
continue
w.u8(1)
for key in ('t', 'r', 's'):
comps = tr.get(key)
if comps is None:
# 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
rest = {'t': [0, 0, 0], 'r': [0, 0, 0],
's': [1.0, 1.0, 1.0]}[key]
src = bones[bi][key] if key in bones[bi] else rest
comps = list(src)
for c in comps:
write_track_component(w, c, key)
for a in aux:
w.u16(a['frames'])
w.u16(a.get('loopStart', 0))
chans = a['channels']
w.u16(len(chans))
for ch in chans:
w.u16(len(ch))
for v in ch:
w.u16(v)
return w.bytes(), (height, floor, radius)
# ---------------------------------------------------------------------- main
def build_one(blob, fileno, tables, moves):
"""One model fragment -> the payload `pack` takes.
The same three steps build.py takes for a Pokemon, in the same order:
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.
"""
data = fragment.extract(blob, '%d.bin' % fileno, raw=True)
species = data['species']
rows = tables.rows(species)
build.label_animations(data, rows, moves)
build.attach_effects(data, species, raw=True)
data['name'] = battle.SPECIES.get(species, '#%d' % species)
return data, species, rows
def main(argv):
args = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True)
for a in argv}
out = args.get('--out') or os.path.join(MOD, 'assets', 'stadium')
only = ({int(x) for x in args['--only'].split(',')}
if '--only' in args else None)
rom_path = args.get('--rom') or build.find_rom()
if not rom_path or not os.path.exists(rom_path):
print('ROM not found. Put a Pokemon Stadium (US 1.0) ROM at\n %s\n'
'or pass --rom=PATH.'
% os.path.join(build.BASEROMS, 'baserom.z64'), file=sys.stderr)
return 1
rom = rom_mod.Rom(rom_path)
if not rom.is_expected_us:
print('warning: md5 %s is not the expected US 1.0 ROM' % rom.md5,
file=sys.stderr)
blobs = rom_mod.pokemon_models(rom)
tables = battle.BattleTables(rom)
moves = battle.load_move_names(os.path.join(MOD, 'model_extract'))
os.makedirs(out, exist_ok=True)
total, report, statics = 0, [], []
for fileno in range(min(N_POKEMON, len(blobs))):
data, species, rows = build_one(blobs[fileno], fileno, tables, moves)
if only and species not in only:
continue
move_rows = [[rows[m][0], rows[m][1]] for m in range(N_MOVES)]
blob, extent = pack(data, species, move_rows,
context_table(rows, len(data['anims'])))
if blob[4 + 12 + 4] == 1:
statics.append(species)
path = os.path.join(out, '%03d.dsm' % species)
with open(path, 'wb') as fp:
fp.write(blob)
total += len(blob)
report.append((species, data['name'], len(blob), extent,
len(data['bones']), len(data['prims']),
len(data['anims'])))
print('%d models, %.1f MB -> %s' % (len(report), total / 1e6, out))
if statics:
print('held at the bind pose (corrupt standby loop in the source): %s'
% ', '.join(str(x) for x in statics))
if '--report' in args:
report.sort(key=lambda r: -r[3][0])
print('%-4s %-12s %9s %8s %8s %6s %6s %5s'
% ('#', 'name', 'bytes', 'height', 'floor', 'bones', 'prims',
'anims'))
for r in report[:10] + report[-5:]:
print('%-4d %-12s %9d %8.2f %8.2f %6d %6d %5d'
% (r[0], r[1], r[2], r[3][0], r[3][1], r[4], r[5], r[6]))
heights = sorted(r[3][0] for r in report)
print('height: min %.2f median %.2f max %.2f'
% (heights[0], heights[len(heights) // 2], heights[-1]))
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))