enforce 1.0 stadium rom

This commit is contained in:
DramaticShape
2026-08-04 14:18:11 -04:00
parent 7ce268e5a2
commit b7ce0f21d5
20 changed files with 591 additions and 324 deletions
+85 -8
View File
@@ -62,14 +62,13 @@
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
The animations are driven from the fight itself: 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), fainting plays the faint and holds on its last
frame, and a send-out grows the Pokemon out of the ball as it opens and
plays the entrance with it. 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.
@@ -152,6 +151,15 @@
external-files folder, so `baseroms/` there is reachable over USB or a
file manager without root.
- **Acknowledgement for [pret/pokestadium](https://github.com/pret/pokestadium)**,
in README.md, model_extract/README.md and mod.card. The STADIUM extractor is
original code, but the decompilation is what it was written against -- the
bone matrix chain and the fact that scale is kept out of it, the rotation
basis, the animation and texture-animation samplers, the battle context
slots and the move-id constants all came from reading that project. None of
its code or data is vendored here or needed to run this, and the credit says
so as plainly as it says what was owed.
- `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
@@ -286,6 +294,75 @@
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.
- **Being hit played the ATTACK animation.** The context slot the mod called
`hit` is not a damage reaction: read against the move table, Bulbasaur's is a
95-frame animation that 66 of its moves play, and Pidgey's is 138 frames --
four and a half seconds -- shared by 111 of its moves. That is the species'
DEFAULT ATTACK, which is why taking damage looked exactly like swinging: it
was the swing. Slots 173, 178, 179, 180 and 181 all point at the same one.
Nor is a reaction hiding elsewhere. Exactly one animation per species is
claimed by no slot and no move, and it is the same length as that species'
idle for essentially all of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a
second standby loop, not a recoil. **This set has no damage reaction in it.**
So damage now plays nothing and the Pokemon carries on with what it was
doing, which is what the engine already communicates through its own screen
flash, pic blink and HP drain. The slot is renamed `attack_default`
throughout (a label on a position -- the file format is the ORDER, so no
bytes changed), and the generic swing a move with no table entry falls back
to now uses it rather than resolving to the standby loop.
- **The battle menu no longer changes colour with the scenery.** There was a
pass that measured each frosted panel's average brightness and flipped the
glyphs to white over a dark one, with hysteresis so a drifting camera could
not strobe them. It worked, and it was still wrong: the battle menu is the
part of the frame the player reads constantly, and having its colour depend
on where the camera happens to point makes it unreliable furniture. Gen 1's
battle ink is black, so it is black -- on a cave floor as much as on a
meadow -- and the panel's tint, which always pushes toward white, is what
earns it its contrast. Removing it also took out a one-pixel GPU readback
that ran several times a second to answer a question nothing asks any more.
- **The required ROM is now named everywhere: Pokemon Stadium (US) 1.0.** It
always was the only one that works -- every offset in the reader was
measured against that cartridge -- but the docs and the file picker just
said "Pokemon Stadium (US)", which is three different ROMs. The picker's
title, the OPTIONS help, the README, mod.card and the manifest all name the
revision now, the README carries the reference md5
(`ed1378bc12115f71209a77844965ba50`) so a player can check their own file,
and a build from anything else says so on the loading screen as well as the
console rather than quietly producing wrong models.
- **The extraction screen just says STADIUM EXTRACTION now**, with the
progress bar and the species name under it; the "this runs once" line is
gone.
- **Pokemon now grow out of the ball, instead of appearing beside it.**
Measured, the send-out is: the ball is thrown, the POOF animation runs for
27 frames, and `startGrowIn` fires on the frame AFTER it ends. So the model
did not begin to exist until the ball had finished opening -- the ball came
apart, and then a Pokemon was switched on next to it.
The engine's own ramp is the Game Boy's three steps (0, 3/7, 5/7, full)
across the twelve frames after that, which on a 56-pixel sprite is a chunky
pop and on a smooth 3D model is just a pop.
The model now runs its own ramp, started when the POOF BEGINS and
continuous: it grows out of nothing while the ball is coming apart and
reaches full size exactly as the engine's own grow finishes -- 39 frames
end to end, which is the poof's 27 plus the engine's 12. Smoothstep rather
than linear or ease-out, because the ball is still opening through the first
half and a curve that was already near full size by then would have the
Pokemon standing about waiting for it. The entrance animation starts with
the grow, so the arrival is one performance rather than a grow followed by
a flourish.
Its own ramp OWNS the arrival once it starts: falling back to the engine's
afterwards shrank the Pokemon from 0.96 back to 0.71 and then snapped it to
full, because the two finish a few frames apart -- a visible hitch at the
end of the one animation that exists to not have one.
- **The first Pokemon of a battle arrived, left, and arrived again.** Every
guard deciding whether the player's Pokemon is on the field is a field the
engine sets once the battle is RUNNING, and during the opening none of them
+56 -9
View File
@@ -100,10 +100,15 @@ floors that have nowhere to put a fight. Only the STADIUM rungs need a ROM;
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.
does put Diglett into the ground. Fainting plays the faint and holds there, a
send-out grows the Pokémon out of the ball as it opens and 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.
Taking damage plays nothing, because the set has no damage reaction in it —
the slot that looked like one is each species' default attack, which is why
being hit used to look like swinging. The engine's own screen flash, pic blink
and HP drain are what say "that hurt".
148 of the 151 have models. Exeggutor, Tangela and Magmar come out of the ROM
with corrupt standby loops and stand as their Game Boy battle sprites
@@ -124,9 +129,19 @@ under that cave's void and its own flat light.
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.
> **You must supply a Pokémon Stadium (US) 1.0 ROM.** Not Stadium 2, not
> another region, not a later revision. Every offset in the reader was
> measured against that one cartridge, and nothing else is promised: a
> different file is either refused outright or builds models that are subtly
> wrong. The mod checks, and says so — on the console, and on the loading
> screen itself if it built from something unexpected.
>
> The reference dump is **md5 `ed1378bc12115f71209a77844965ba50`**, 32 MB.
> The mod does not tell you where to get one, and none ships with it.
1. Open **OPTIONS** and press the **STADIUM ROM** row. It opens your system's
file picker; choose a **Pokémon Stadium (US)** ROM. `.z64`, `.n64` and
`.v64` all work — the byte order is detected, and the wrong file is
file picker; choose your **Pokémon Stadium (US) 1.0** ROM. `.z64`, `.n64`
and `.v64` all work — the byte order is detected, and the wrong file is
refused with a reason rather than half-built.
2. The 151 models are built on a loading screen that says so and shows a
progress bar, in about ten seconds. The row then reads **READY**.
@@ -138,8 +153,8 @@ produced. Press the row again any time to import a different one.
There is no picker on Android, or on a Linux install with neither `zenity`
nor `kdialog`. Those keep the original route, which still works everywhere:
- Put the ROM in a `baseroms/` folder beside the game — straight in it, not
in a subfolder — and start the game.
- Put the **US 1.0** ROM in a `baseroms/` folder beside the game — straight
in it, not in a subfolder — and start the game.
- In a packaged build (and on Android) `baseroms/` goes in the save
directory; the mod logs the exact path on startup when it cannot find one.
On Android that is the app's external-files folder, reachable over USB or
@@ -216,4 +231,36 @@ geometry and shape profiles are derived from the tile and sprite data of
the original game, as documented by the
[pret/pokered](https://github.com/pret/pokered) disassembly. No ROM
data, artwork or audio is included; the mod reads the assets the host
game already has.
game already has.
### Acknowledgements — pret/pokestadium
The STADIUM battle models are read out of the player's own Pokémon Stadium
(US) 1.0 cartridge by original code in [`lib/`](lib) and
[`model_extract/`](model_extract). **That code exists because of
[pret/pokestadium](https://github.com/pret/pokestadium)**, the community
decompilation of that game, which is the reference this mod's reader was
written against. Specifically, it is where the following came from:
- the bone matrix chain, and the fact that scale is kept *out* of it and
applied only at draw time (`func_800143C0`) — the single most important
thing to get right in the whole rig, and not guessable from the data
- the rotation basis and its row-vector `Rx·Ry·Rz` order
(`func_8000F730`, `src/F420.c`)
- the animation player's frame counter and loop-start behaviour
(`func_80016FBC`), and the texture-animation sampler that *clamps* past
the end of its stream rather than wrapping (`func_80017540`) — which is
the difference between a Pokémon blinking and twitching
- the battle system's per-species animation context slots and the routines
that select them (`func_8432B0A4`, `func_8430506C`, `func_84305A74`)
- the move-id constants the per-species move table is keyed by
**No code, data or asset from that project is included in or redistributed
by this mod**, and none is needed to build or run it. What was taken is an
understanding of the file formats, re-expressed in this mod's own Lua and
Python. If you want to reuse anything from the decompilation itself, get it
from upstream and follow that project's own terms.
No Pokémon Stadium ROM data ships here either. The models are built on the
player's own machine, from a cartridge they supply, into their own save
directory — see [Getting the models](#getting-the-models).
+20 -197
View File
@@ -12,15 +12,17 @@
-- and an opaque slab in the corner of the frame is the white field back
-- again by another name.
--
-- And the text flips. A panel over a sunlit meadow is bright and wants black
-- glyphs; the same panel over a cave floor or a dark roof is not, and wants
-- white ones. So the panel's average brightness is measured and the glyphs
-- follow it, with hysteresis so a slow camera drift across the threshold
-- cannot strobe them.
-- The ink does NOT change. There was a pass here that measured each panel's
-- average brightness and flipped the glyphs to white over a dark one, with
-- hysteresis so a drifting camera could not strobe them. It worked, and it
-- was still wrong: the battle menu is the one part of the frame the player
-- reads constantly, and having its colour depend on what the camera happens
-- to be pointing at makes it an unreliable piece of furniture. Gen 1's
-- battle text is black, so it is black -- and the panel's tint is what
-- earns that its contrast, on a cave floor as much as on a meadow.
--
-- The measurement is a one-pixel readback, which is a GPU stall, so it runs
-- a few times a second rather than every frame. The camera drifts at about
-- a pixel a second; brightness cannot outrun that.
-- Removing it also took out a one-pixel GPU readback that ran several times
-- a second purely to answer a question nothing asks any more.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
@@ -37,24 +39,13 @@ local BattleHud = {}
BattleHud.FROST = 0.55
BattleHud.TINT = 0.26
-- The luminance the glyphs flip at, with a dead band so a drift across it
-- settles rather than strobes.
BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs
BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones
-- Frames between brightness readbacks.
BattleHud.SAMPLE_EVERY = 12
-- The frost buffer's height; width follows the source's aspect. Small on
-- purpose: the downscale is most of the blur, and what is read back for the
-- brightness is one pixel of it.
-- purpose: the downscale is most of the blur.
BattleHud.FROST_H = 72
local frost, frostW, frostH = nil, 0, 0
local blurA, blurB = nil, nil
local probe = nil
local frame = 0
local luma = {} -- panel key -> { value, dark, at }
local SHADER = [[
uniform vec2 dir;
@@ -102,7 +93,6 @@ function BattleHud.build(src)
frost = canvasOf(w, h)
blurA = canvasOf(w, h)
blurB = canvasOf(w, h)
probe = probe or canvasOf(1, 1)
if not (frost and blurA and blurB) then
frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
return nil
@@ -149,46 +139,6 @@ function BattleHud.frame()
return frame
end
-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels.
--
-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel
-- canvas, which IS the mean, and that one pixel is read back. Cached for
-- SAMPLE_EVERY frames because the readback synchronises the pipeline and
-- nothing it measures moves faster than that.
local function sampleLuma(key, fx, fy, fw, fh)
local hit = luma[key]
if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end
if not (frost and probe and frostW > 0) then return hit and hit.value end
if fw <= 0 or fh <= 0 then return hit and hit.value end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local value = hit and hit.value or 1
local ok = pcall(function()
love.graphics.setCanvas(probe)
love.graphics.setBlendMode("replace", "premultiplied")
love.graphics.setColor(1, 1, 1, 1)
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh)
love.graphics.setCanvas()
local data = probe:newImageData()
local r, g, b = data:getPixel(0, 0)
if data.release then pcall(data.release, data) end
value = 0.299 * r + 0.587 * g + 0.114 * b
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then return hit and hit.value end
luma[key] = { value = value, at = frame }
return value
end
-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
-- in the source the frost was built from.
local function frostRect(rect, box)
@@ -218,44 +168,14 @@ local function mapper(world)
return world and frostRectWorld or frostRect
end
-- ------- the verdict
--
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
-- single pass and there is only one glyph colour to be had out of it -- and
-- a frame with a black-lettered HUD in one corner and a white-lettered one
-- in the other would read as a bug rather than as adaptation. The DARKER
-- panel decides, because it is the one that cannot afford to be wrong, and
-- the tint below then commits both panels to that reading.
local wasDark = false
function BattleHud.verdict(rects, box, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local toFrost = mapper(world)
local darkest = nil
for key, rect in pairs(rects) do
local fx, fy, fw, fh = toFrost(rect, box)
local v = sampleLuma(key, fx, fy, fw, fh)
if v and (not darkest or v < darkest) then darkest = v end
end
if not darkest then return wasDark end
-- hysteresis: it takes a clear move past the far threshold to flip back,
-- so a camera drifting across the boundary settles instead of strobing
if wasDark then
wasDark = darkest < BattleHud.DARK_LEAVE
else
wasDark = darkest < BattleHud.DARK_ENTER
end
return wasDark
end
-- Draw one HUD panel into the current target, in that target's own
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
-- for a panel laid straight onto the world image.
--
-- The tint always pushes AWAY from the glyph colour that is about to be
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
-- gets darker under white text, a bright one brighter under black text.
function BattleHud.panel(rect, box, dark, world)
-- The tint always pushes toward WHITE, away from the black ink that is about
-- to land on it, so the contrast is guaranteed rather than hoped for -- and
-- it is the whole of what makes a fixed ink colour workable over any ground.
function BattleHud.panel(rect, box, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local fx, fy, fw, fh = mapper(world)(rect, box)
local ok = pcall(function()
@@ -263,93 +183,13 @@ function BattleHud.panel(rect, box, dark, world)
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
love.graphics.draw(frost, quad, rect[1], rect[2], 0,
rect[3] / fw, rect[4] / fh)
local shade = dark and 0 or 1
love.graphics.setColor(shade, shade, shade, BattleHud.TINT)
love.graphics.setColor(1, 1, 1, BattleHud.TINT)
love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
love.graphics.setColor(1, 1, 1, 1)
end)
return ok
end
-- ------- flipping the glyphs
--
-- Over a dark panel the HUD's black text has to go white, and it cannot be
-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph
-- times white is still black. The colour channel has to be REPLACED.
--
-- So the HUD is drawn into a scratch layer and that layer is composited back
-- through a shader that whitens whatever is nearly black and leaves the rest
-- alone. "Nearly black" is the text, the tick marks and the bar's outline --
-- everything the HUD draws as ink -- while the HP bar's own greens and reds
-- are well clear of the threshold and come through untouched.
--
-- Composited back into whatever the caller had bound, which is what makes it
-- work in both pipelines without knowing which one it is in: in the colorized
-- one that target is the grayscale BG canvas, where white IS shade 0 and the
-- zone pass then colours the flipped glyphs like every other lightest-shade
-- surface; in the flat fallback it is the screen, where white is white.
local INK = 0.35 -- luminance at or under which a pixel counts as ink
local FLIP = [[
uniform float ink;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114));
if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a);
return p * color;
}
]]
local flipShader = nil
local layer = nil
local function getFlip()
if flipShader == nil then
local ok, sh = pcall(love.graphics.newShader, FLIP)
flipShader = (ok and sh) or false
end
return flipShader or nil
end
-- Whether the flip pass can run at all, for the shot driver's log.
function BattleHud.flipReady()
return getFlip() ~= nil
end
-- Run `fn` with its ink whitened. Falls back to running it plainly when the
-- scratch layer or the shader is unavailable, so a driver that cannot do
-- either gets the vanilla black HUD rather than no HUD.
function BattleHud.flipGlyphs(w, h, fn)
local sh = getFlip()
if not sh then return fn() end
if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then
layer = canvasOf(w, h, "nearest")
if not layer then return fn() end
end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local ok, err = pcall(function()
love.graphics.setCanvas(layer)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setBlendMode("alpha")
fn()
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then error(err, 0) end
love.graphics.setShader(sh)
pcall(sh.send, sh, "ink", INK)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(layer, 0, 0)
love.graphics.setShader()
end
-- ------- the whole HUD layer as a texture
--
-- The two blocks do not sit in the same place any more: each is snapped to its
@@ -358,13 +198,9 @@ end
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
-- each block is then blitted out of it as a quad.
--
-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
-- into the texture rather than composited into the caller's target -- the world
-- image the quads land on is a colour canvas, and a flip pass over it would
-- whiten the terrain behind the glyphs along with them.
local hudLayer = nil
function BattleHud.layerTexture(w, h, dark, fn)
function BattleHud.layerTexture(w, h, fn)
if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
hudLayer = canvasOf(w, h, "nearest")
if not hudLayer then return nil end
@@ -377,9 +213,7 @@ function BattleHud.layerTexture(w, h, dark, fn)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
-- flipGlyphs renders fn into its own scratch layer and composites the
-- whitened result into whatever is bound, which is this canvas
if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
fn()
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
@@ -388,21 +222,10 @@ function BattleHud.layerTexture(w, h, dark, fn)
return hudLayer
end
-- The last luminance measured, for the shot driver's log.
function BattleHud.lastLuma()
local best = nil
for _, hit in pairs(luma) do
if not best or hit.value < best then best = hit.value end
end
return best
end
function BattleHud.invalidate()
frost, blurA, blurB, probe = nil, nil, nil, nil
frost, blurA, blurB = nil, nil, nil
frostW, frostH = 0, 0
luma = {}
wasDark = false
layer, hudLayer = nil, nil
hudLayer = nil
end
return BattleHud
+12 -46
View File
@@ -1272,18 +1272,14 @@ function OverworldBattle.install()
end
-- The battle's text box and its menus, over the frosted glass laid down for
-- them rather than over their own white paper -- and their ink flipped with
-- the HUD's when the ground under the frame is dark, by the same rule and
-- off the same verdict.
-- them rather than over their own white paper. The INK is Gen 1's own black
-- and stays that way whatever is behind the glass -- the panel's tint is
-- what earns it its contrast (see BattleHud).
local innerText = BattleState.drawTextArea
function BattleState:drawTextArea()
if not self.dramaticShapeShot then return innerText(self) end
if isIOS() then return innerText(self) end
local battle = self
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
withoutBoxFill(battle, innerText)
end)
return withoutBoxFill(self, innerText)
end
-- Move animations are authored against the pics' fixed slots, and a single
@@ -1376,28 +1372,13 @@ function OverworldBattle.install()
if not ok then error(err, 0) end
end
-- Black glyphs on grass are not readable; over a frosted panel measured
-- dark they are not readable either, so they go white. Mapped rather than
-- rewritten: the HUD sets pure black for its text and nothing else, and in
-- the colorized pipeline this lands in the grayscale BG canvas, where
-- white IS shade 0 and the zone pass then colours it like every other
-- lightest-shade surface. One rule, both pipelines.
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
-- Normally the HUDs have already been drawn this frame, snapped out to the
-- window's edges and composited into the world image (snapHUDs). Drawing
-- them here as well would show each block twice, once in each place.
if self.dramaticShapeShot and snapped() then return end
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
local battle = self
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
innerHUDs(battle, slide)
end)
return innerHUDs(self, slide)
end
BattleState.dramaticShapeBattleHook = true
@@ -1429,18 +1410,17 @@ end
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
-- answered false, it tints its own greens and reds instead, exactly as it does
-- on the flat path. The glyphs are pure black either way, which is what the
-- flip in BattleHud.layerTexture is measured against.
-- on the flat path.
--
-- Shadowed on the instance for this call only, the way drawZonePass shadows
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
-- class method be found again.
function OverworldBattle.hudTexture(battle, slide, dark)
function OverworldBattle.hudTexture(battle, slide)
if not (innerHUDs and battle) then return nil end
local had = rawget(battle, "colorMode")
battle.colorMode = function() return false end
local ok, layer = pcall(BattleHud.layerTexture,
BattleScene.GB_W, BattleScene.GB_H, dark,
BattleScene.GB_W, BattleScene.GB_H,
function() innerHUDs(battle, slide) end)
battle.colorMode = had
return ok and layer or nil
@@ -1479,16 +1459,7 @@ function OverworldBattle.snapHUDs(battle, shot)
for key, rect in pairs(OverworldBattle.textRects(battle)) do
live[key] = toWorld(rect, shot)
end
-- measured under the SNAPPED rects: the panels are over whatever the world
-- shows at the window's edges now, which is not what was behind them in the
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
-- corner and black ones on the menu reads as a bug rather than as adaptation.
local dark = BattleHud.verdict(live, shot, true)
-- the box's own ink is flipped where the engine draws it, in the GB frame,
-- so the answer has to outlive this function (see drawHudPanels)
if session then session.dark = dark end
local layer = OverworldBattle.hudTexture(battle, slide, dark)
local layer = OverworldBattle.hudTexture(battle, slide)
if not layer then return false end
local g = love.graphics
@@ -1497,7 +1468,7 @@ function OverworldBattle.snapHUDs(battle, shot)
local ok, err = pcall(function()
g.setCanvas(shot.canvas)
g.setBlendMode("alpha")
for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end
g.setColor(1, 1, 1, 1)
for side, band in pairs(OverworldBattle.HUD_BAND) do
local quad = g.newQuad(band[1], band[2], band[3], band[4],
@@ -1522,7 +1493,6 @@ end
-- drawn here, in the GB frame, whichever path laid the glass under it.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot then return end
if isIOS() then
local slide = (battle.introSlide or 0) * 4
@@ -1532,11 +1502,9 @@ function OverworldBattle.drawHudPanels(battle)
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
love.graphics.setColor(1, 1, 1, 1)
battle.dramaticShapeDark = nil
return
return
end
if snapped() then
battle.dramaticShapeDark = session and session.dark or nil
return
end
local slide = (battle.introSlide or 0) * 4
@@ -1547,9 +1515,7 @@ function OverworldBattle.drawHudPanels(battle)
if player then live.player = rect.player end
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
if not next(live) then return end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
for _, r in pairs(live) do BattleHud.panel(r, shot) end
end
return OverworldBattle
+63 -20
View File
@@ -202,10 +202,18 @@ end
local function onField(battle, side, mon)
local battler = side == "player" and battle.player or battle.enemy
if not (battler and battler.sprite) then return false end
-- A model that is GROWING out of its ball is on the field by definition --
-- that is what the grow is -- even though the engine still calls the side
-- "sending out", because the flat pic it wrote that flag for does not
-- appear until the ball has finished opening and this one comes out with
-- it (see StadiumMon.GROW_TIME).
local growing = (mon and mon.grow) and true or false
if side == "enemy" then
if battle.enemyHidden or battle.enemySendingOut then return false end
if battle.enemyHidden then return false end
if battle.enemySendingOut and not growing then return false end
else
if battle.safari or battle.demo or battle.sendingOut then return false end
if battle.safari or battle.demo then return false end
if battle.sendingOut and not growing then return false end
-- ------- and not before the battle has even opened
--
-- The player's Pokemon is not out during the INTRO. Every other guard
@@ -359,6 +367,8 @@ function Stadium.update(dt, battle, groundY)
-- a send-out and a new battle alike.
if session.at[side] ~= battler then
session.at[side] = battler
-- a fresh arrival: this Pokemon has not grown out of its ball yet
if mon then mon.grow, mon.grewOwn = nil, nil end
if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
end
-- the collapse this side is owed, once its bar has finished emptying
@@ -386,10 +396,35 @@ function Stadium.update(dt, battle, groundY)
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
-- ------- the ball is opening: start growing out of it
--
-- The POOF is the ball coming apart, and it is where a Pokemon should
-- begin to exist -- not 27 frames later when the engine starts scaling
-- up the flat pic it was written for. Only for a side the battle says
-- is actually sending out, so the same animation played at a thrown
-- Poke Ball (a capture attempt, which aims it at the FOE) cannot start
-- the wrong Pokemon growing.
local poof = (battle.animPlaying
and battle.animName == "POOF_ANIM") and true or false
local sending = (side == "player") and battle.sendingOut
or battle.enemySendingOut
if poof and sending and mon:beginGrow() then
-- and the arrival animation with it, so the whole thing is one
-- performance rather than a grow followed by a flourish
mon:request("entrance")
end
-- how big it is drawn. Its own ramp while it is growing (see
-- StadiumMon.growScale); the engine's three-step one otherwise, which
-- still covers a send-out that never showed a poof.
if mon.grow then
mon.scale = mon:growScale()
elseif mon.grewOwn then
mon.scale = 1
else
local okG, grow = pcall(battle.growInScale, battle, battler)
mon.scale = (okG and grow) or 1
end
mon:update(dt or 0)
if mon.visible and arena then
local cell = arena[side]
@@ -501,6 +536,15 @@ function Stadium.showing(side)
return (mon and mon.visible) and true or false
end
-- How big this side's model is being drawn this frame, 0..1 -- the send-out
-- grow. Named for the shot drivers: a ramp is a curve over time and a
-- screenshot has one point of it.
function Stadium.scaleOf(side)
if not session then return nil end
local mon = session[side]
return mon and mon.scale 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)
@@ -565,19 +609,11 @@ function Stadium.install()
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 HIT is deliberately NOT hooked. There is no damage reaction in this
-- set to play -- what looked like one is the species' default attack (see
-- StadiumMon's STATES), which is why being hit used to look like swinging.
-- The engine's own flash, pic blink and bar drain are what say "that hurt",
-- and they are already in the frame.
-- THE FAINT. Held on its last frame rather than looped (see StadiumMon's
-- STATES), because a Pokemon that collapses and then stands back up
@@ -604,7 +640,14 @@ function Stadium.install()
-- battle system's own entrance slot names.
local innerGrow = BattleState.startGrowIn
function BattleState:startGrowIn(battler)
if session then ask(self, battler, "entrance") end
if session then
-- unless the model is already on its way out of the ball, in which
-- case the entrance started with the POOF (see update) and asking
-- again here would restart it a third of a second in
local side = sideOf(self, battler)
local mon = side and session[side]
if not (mon and mon.grow) then ask(self, battler, "entrance") end
end
return innerGrow(self, battler)
end
+3 -2
View File
@@ -41,7 +41,7 @@ local roundHalfEven = StadiumFragment.roundHalfEven
-- 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",
"idle", "attack_default", "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",
@@ -49,7 +49,8 @@ StadiumBuild.CONTEXTS = {
}
-- Which context name wins when several claim the same animation.
local NAME_PREF = { "idle", "hit", "faint", "entrance", "struggle", "flinch" }
local NAME_PREF = { "idle", "attack_default", "faint", "entrance",
"struggle", "flinch" }
local N_MOVES = StadiumRom.N_MOVES
local CTX_BASE = 165
+17 -6
View File
@@ -6,7 +6,8 @@
-- 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
-- So: supply a Pokemon Stadium (US) 1.0 ROM -- the OPTIONS row opens a file
-- picker for one, or drop it 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.
@@ -230,10 +231,20 @@ function StadiumInstall.beginFrom(bytes, label)
local StadiumBuild = V.require("StadiumBuild")
local rom, err = StadiumRom.open(bytes)
if not rom then return false, tostring(err) end
status.wrongVersion = false
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",
tostring(label or "the ROM"), tostring(rom:md5()))
-- Built anyway rather than refused: a dump can differ from the reference
-- for reasons that do not move a single model offset (a byte-order
-- variant already normalised on load, a trimmed overdump). But every
-- offset in this reader was measured against US 1.0 and nothing else is
-- promised, so it is said loudly, with the md5 that IS expected so the
-- player can check their own file against it.
status.wrongVersion = true
V.mod.log:warn("stadium: %s is md5 %s -- the model offsets are keyed to "
.. "Pokemon Stadium (US) 1.0, which is md5 %s. Building "
.. "anyway, but the models may be wrong or fail to build.",
tostring(label or "the ROM"), tostring(rom:md5()),
tostring(StadiumRom.US_MD5))
end
-- ------- refuse a ROM with no models in it, BEFORE anything is written
@@ -255,7 +266,7 @@ function StadiumInstall.beginFrom(bytes, label)
-- possibly produce a build.
local models = rom:modelCount()
if not (models and models >= StadiumInstall.COUNT) then
return false, "that is not a Pokemon Stadium ROM"
return false, "needs Pokemon Stadium US 1.0"
end
pcall(f.createDirectory, StadiumInstall.DIR)
@@ -304,7 +315,7 @@ function StadiumInstall.step()
-- on a few. Worth telling apart, because "0 of 151 models were built"
-- reads as a broken mod and this reads as a wrong click.
if #job.failed >= job.total then
status.error = "that is not a Pokemon Stadium ROM"
status.error = "needs Pokemon Stadium US 1.0"
else
status.error = ("%d of %d models could not be built")
:format(#job.failed, job.total)
+92 -12
View File
@@ -106,6 +106,26 @@ StadiumMon.HOVER_CAP = 0.5
-- same rate.
StadiumMon.FPS = StadiumPack.FPS
-- ------- coming out of the ball
--
-- The engine grows its flat pic in the Game Boy's own three steps -- 0, then
-- 3/7, then 5/7, then full -- across the twelve frames after the ball opens
-- (BattleState.growInScale). Two things about that do not carry to a model.
--
-- It is three steps, which on a 56-pixel sprite is a chunky pop and on a
-- smooth 3D model is just a pop. And it starts AFTER the ball: measured, the
-- poof animation runs for 27 frames and `startGrowIn` fires on the frame
-- after it ends, so the Pokemon does not begin to exist until the ball has
-- finished opening -- which reads as the ball opening and then a Pokemon
-- being switched on beside it.
--
-- So the model runs its own ramp, started when the POOF begins rather than
-- when it ends, and continuous rather than stepped: it grows out of nothing
-- while the ball is opening and reaches full size as the engine's own grow
-- finishes. GROW_TIME is measured off that -- 27 frames of poof plus the
-- engine's 12 of grow is 39, which is this.
StadiumMon.GROW_TIME = 0.65
-- How far an animation may carry the Pokemon off its tile, in the Pokemon's
-- own body-heights, before the excess is taken back out (StadiumRig.anchor).
--
@@ -122,15 +142,37 @@ StadiumMon.TRAVEL = 0.75
--
-- 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.
-- ------- there is no hit reaction, and there never was
--
-- This used to carry `hit` and `flinch` states, played when damage landed,
-- resolving through context slots 166 and 178. Both were wrong, and the data
-- says so plainly once the move table is read alongside them:
--
-- Bulbasaur's slot 166 is a 95-frame animation that 66 of its moves play.
-- Pidgey's is 138 frames -- four and a half seconds -- and 111 of its moves
-- play it. Slot 178, and 173, 179, 180 and 181, all point at the same one.
--
-- A four-and-a-half-second animation that most of the move table uses is the
-- species' DEFAULT ATTACK, not a flinch, which is why being hit looked like
-- swinging: it literally was the swing.
--
-- Nor is the reaction hiding elsewhere. Exactly one animation per species is
-- claimed by no slot and no move, and it is the same length as the idle for
-- essentially every one of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a
-- second standby loop, not a recoil. The set has no damage reaction in it.
--
-- So damage plays nothing, and the Pokemon carries on with what it was doing.
-- That is not a gap: the engine flashes the screen, blinks the pic and drains
-- the bar, which is how Gen 1 says "that hurt" and is already in the frame.
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" },
-- A move names its own animation out of the move table. `attack_default`
-- is the fallback for one the table has nothing for -- which is what slot
-- 166 actually is, so the generic swing is now a real swing rather than
-- the standby loop it used to resolve to.
attack = { slot = "attack_default", loop = false, next = "idle" },
}
function StadiumMon.new(side)
@@ -193,6 +235,7 @@ 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
self.grow, self.grewOwn = nil, nil
if not dex then return false end
local model = StadiumPack.load(dex)
if not model then return false end
@@ -250,19 +293,17 @@ function StadiumMon:play(state, animIndex, auxIndex)
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 }
-- final, and an entrance cannot be cut short by the standby loop it hands
-- on to.
local RANK = { idle = 0, entrance = 1, attack = 2, faint = 3 }
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
-- an equal-ranked request RESTARTS: the second move of a two-hit turn
-- should swing again rather than be swallowed by the first
if want < now then return false end
return self:play(state, animIndex, auxIndex)
end
@@ -291,6 +332,12 @@ function StadiumMon:update(dt)
-- advance the anchor's filter (StadiumRig.anchor). Stashed before the
-- early-outs below, so a species with nothing to play still has one.
self.dt = dt or 0
-- the ball-to-full-size ramp, which runs whether or not there is an
-- animation to play alongside it
if self.grow then
self.grow = self.grow + (dt or 0) / StadiumMon.GROW_TIME
if self.grow >= 1 then self.grow = nil end
end
local model = self.model
if not (model and self.anim) then return end
local anim = model.anims[self.anim]
@@ -314,6 +361,39 @@ function StadiumMon:update(dt)
end
end
-- ------- the grow
--
-- Begin coming out of the ball. Answers whether it actually started, so the
-- caller can play the entrance alongside it and the engine's own send-out
-- seam a moment later does not restart what is already running.
function StadiumMon:beginGrow()
if self.grow or not self.model then return false end
self.grow = 0
-- and remember that THIS arrival was ours to size, so the engine's own
-- three-step ramp is not consulted again for it. Ours starts earlier and
-- finishes a few frames sooner, and in that gap the engine's ramp still
-- reads 5/7 -- so falling back to it shrank the Pokemon from 0.96 back to
-- 0.71 and then snapped it to full, a visible hitch at the end of an
-- animation that exists to not have one.
self.grewOwn = true
return true
end
-- How big this Pokemon is drawn this frame, as a fraction of its real size.
--
-- Smoothstep rather than a straight ramp or an ease-out: the ball is opening
-- for the first half of this, so a curve that is already near full size by
-- then would have the Pokemon standing there while the ball is still coming
-- apart. Slow, then quick through the middle, then settling exactly as the
-- engine's own grow ends.
function StadiumMon:growScale()
local t = self.grow
if not t then return 1 end
if t <= 0 then return 0 end
if t >= 1 then return 1 end
return t * t * (3 - 2 * t)
end
-- Whether a HELD animation -- which in practice means a faint -- has played
-- all the way through and is now sitting on its last frame. Always false for
-- a looping one, which never finishes, and for one that hands on to another
+6 -1
View File
@@ -81,8 +81,13 @@ end
-- 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.
-- Position 2 was called "hit" until the move table was read against it: it
-- is the animation most of a species' MOVES play, which makes it the default
-- attack and not a damage reaction (see StadiumMon's STATES). The name is a
-- label on a position -- the format is the ORDER -- so renaming it changes no
-- bytes, but it has to match tools/stadium_pack.py's CONTEXTS.
StadiumPack.CONTEXT = {
"idle", "hit", "faint", "entrance", "reaction_169", "reaction_170",
"idle", "attack_default", "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",
+3 -1
View File
@@ -56,7 +56,9 @@ local StadiumRomPick = {}
StadiumRomPick.LABEL = "STADIUM ROM"
StadiumRomPick.ID = "DRAMATIC_SHAPE:stadiumRom"
local PROMPT = "Choose your Pokemon Stadium ROM"
-- Names the REVISION, because that is the thing a player gets wrong: the
-- model offsets are keyed to US 1.0 and nothing else is going to work.
local PROMPT = "Choose your Pokemon Stadium (US) 1.0 ROM"
-- ------- the host, at arm's length
--
+18 -14
View File
@@ -18,10 +18,7 @@
-- Three things, and each of them is answering a question the player would
-- otherwise have to guess at while the game sits there:
--
-- WHAT is happening -- "ONE-TIME EXTRACTION OF STADIUM ASSETS", in those
-- words, because the two facts that matter to somebody who has just
-- installed a mod and been handed a loading screen are that this is the
-- mod's own asset build and that it is not going to happen again.
-- WHAT is happening -- "STADIUM EXTRACTION", which is what it is.
--
-- HOW FAR through it is -- a bar, filled by species written rather than by
-- elapsed time, so it cannot lie about the remaining work.
@@ -157,8 +154,13 @@ function StadiumScreen:update()
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
local wait = StadiumScreen.HOLD
if status.state == "failed" then
wait = StadiumScreen.HOLD * 4
elseif status.wrongVersion then
-- a warning nobody can read is not a warning
wait = StadiumScreen.HOLD * 3
end
if self.hold >= wait then
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
@@ -186,11 +188,9 @@ function StadiumScreen:draw()
love.graphics.setColor(0.93, 0.94, 0.90, 1)
love.graphics.rectangle("fill", 0, 0, W, H)
centred("POKEMON STADIUM", 16)
-- the headline, in two lines because the frame is 160 pixels wide and the
-- font is a fixed eight: the longer of these is nineteen glyphs
centred("ONE-TIME EXTRACTION", 36)
centred("OF STADIUM ASSETS", 46)
-- One line, and it is the whole heading: eighteen glyphs at the font's
-- fixed eight pixels is 144 of the frame's 160.
centred("STADIUM EXTRACTION", 34)
if status.state == "failed" then
centred("COULD NOT BUILD", 68)
@@ -226,13 +226,17 @@ function StadiumScreen:draw()
if status.state == "done" then
centred("READY", 86)
-- and say so if it was built from something other than the revision every
-- offset in the reader was measured against: it may look fine, it may be
-- subtly wrong, and the player is the only one who can swap the file
if status.wrongVersion then
centred("NOT US 1.0 --", 104)
centred("MODELS MAY BE WRONG", 114)
end
else
local name = speciesName(status.species)
centred(("%d/%d"):format(done, total), 86)
if name then centred(name, 98) end
-- last, and lowest: the reassurance is worth saying and is the least
-- urgent thing on the plate
centred("THIS RUNS ONCE", 122)
end
love.graphics.setColor(1, 1, 1, 1)
end
+4 -2
View File
@@ -437,8 +437,10 @@ local SETTINGS = {
.. "place's own weather and light; B stands it on two discs against the "
.. "sky instead, 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.",
.. "appear once the models have been built, and building them needs a "
.. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the "
.. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
.. "other version works: the reader is keyed to that one cartridge.",
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,
+1 -1
View File
@@ -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. The 3D-BTL row is five rungs across two choices: 2D-3D stands the game's own battle pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models -- 148 of the 151 species, animated, playing the animation the move being used calls for -- while A stages the fight on the map and B on two carried discs against the sky, which works on every map including the ones with nowhere to put a fight. The STADIUM rungs need a Pokemon Stadium (US) ROM of your own: import it with the system file picker from the STADIUM ROM row on the OPTIONS menu, or drop it in a baseroms/ folder, and the models build themselves out of it on a loading screen. 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 is five rungs across two choices: 2D-3D stands the game's own battle pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models -- 148 of the 151 species, animated, playing the animation the move being used calls for -- while A stages the fight on the map and B on two carried discs against the sky, which works on every map including the ones with nowhere to put a fight. The STADIUM rungs need a Pokemon Stadium (US) 1.0 ROM of your own -- no other version works, the reader is keyed to that one cartridge: import it with the system file picker from the STADIUM ROM row on the OPTIONS menu, or drop it in a baseroms/ folder, and the models build themselves out of it on a loading screen. 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"
}
+3 -3
View File
@@ -34,7 +34,7 @@ return {
"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 (2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF, 2D-3D A by default), battles fought in 3D -- 2D-3D stands the game's own pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models, while A stages the fight on the map and B on two carried discs against the sky. Only the STADIUM rungs need a ROM, and they are on the row once those models have been built (see below); 2D-3D B is generated in Lua and needs nothing",
"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",
"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), fainting plays the faint and holds there, and a send-out grows the Pokemon out of the ball and plays the entrance. Damage plays nothing -- the set has no reaction animation in it, and the engine's own flash, blink and HP drain already say so. 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",
@@ -52,7 +52,7 @@ 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. Press STADIUM ROM on the OPTIONS menu to pick one with the system file dialog, or drop a Pokemon Stadium (US) ROM in a baseroms/ folder beside the game; either way the 151 models are built out of it on a loading screen, in about ten seconds, into the save directory, and the ROM itself is not kept. 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 need the Pokemon Stadium battle models, and the mod ships none of them -- they are that game's data. Press STADIUM ROM on the OPTIONS menu to pick one with the system file dialog, or drop one in a baseroms/ folder beside the game. It must be Pokemon Stadium (US) 1.0 (md5 ed1378bc12115f71209a77844965ba50) -- every offset in the reader is keyed to that cartridge, and anything else is refused or builds wrong models; either way the 151 models are built out of it on a loading screen, in about ten seconds, into the save directory, and the ROM itself is not kept. 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",
@@ -77,7 +77,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 = "pret/pokestadium", for_ = "the decompilation the STADIUM extractor was written against -- the bone matrix chain and where scale is applied (func_800143C0), the rotation basis (func_8000F730), the animation and texture-animation samplers (func_80016FBC / func_80017540), the battle context slots and the move-id constants. No code or data from it is included or redistributed here; see README.md" },
{ 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 },
+13
View File
@@ -1,5 +1,18 @@
# Pokemon Stadium (US) — battle model export
> **Built on [pret/pokestadium](https://github.com/pret/pokestadium).** This
> pipeline is original code, but it could not have been written without that
> project's decompilation: the bone matrix chain and the fact that scale is
> kept out of it (`func_800143C0`), the rotation basis (`func_8000F730`), the
> animation and texture-animation samplers (`func_80016FBC`, `func_80017540`),
> the battle context slots, and the move-id constants all came from reading
> it. **No code or data from that project is vendored here or required to run
> this** — see the mod's [README](../README.md#acknowledgements--pretpokestadium),
> and get anything you want to reuse from upstream under its own terms.
>
> No ROM data is committed either: everything below is generated from a
> cartridge you supply.
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:
+52
View File
@@ -1361,6 +1361,58 @@ end)()
"and a whole frame is that frame exactly, with nothing blended into it")
end)()
-- ------- growing out of the ball
--
-- The engine sizes its flat pic in the Game Boy's three steps across the
-- twelve frames AFTER the ball has finished opening. The model runs its own
-- ramp instead, started when the poof begins: continuous, and overlapping the
-- ball rather than following it.
;(function()
if not HAVE_STADIUM_PACKS then return end
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
local Mon = lib.require("StadiumMon")
local mon = Mon.new("player")
T.eq(mon:growScale(), 1, "a Pokemon that is not arriving is full size")
T.eq(mon:beginGrow(), false,
"and one with no model cannot start growing -- there is nothing to size")
mon.model = { height = 10, rootScale = 1 }
T.eq(mon:beginGrow(), true, "with a model, the arrival starts")
T.eq(mon:growScale(), 0, "from nothing at all")
T.eq(mon:beginGrow(), false,
"and starting again is refused -- the engine's own send-out seam fires a "
.. "third of a second later and must not restart the ramp")
-- the curve: slow, then quick through the middle, then settling
local last, monotonic = -1, true
for i = 0, 10 do
mon.grow = i / 10
local s = mon:growScale()
if s < last then monotonic = false end
last = s
end
T.check(monotonic, "the ramp never goes backwards")
mon.grow = 0.5
T.eq(mon:growScale(), 0.5, "and is half size exactly half way through")
mon.grow = 0.25
T.check(mon:growScale() < 0.25,
"slower than linear early, so the Pokemon is still small while the ball "
.. "is coming apart")
-- and it ends, rather than sticking at 0.99
mon.grow = nil
mon.dt = 0
mon:beginGrow()
for _ = 1, 200 do mon:update(1 / 60) end
T.eq(mon.grow, nil, "the ramp finishes")
T.eq(mon:growScale(), 1, "at exactly full size")
T.eq(mon.grewOwn, true,
"and remembers it owned this arrival, so the engine's three-step ramp is "
.. "not consulted for it afterwards -- it reads 5/7 in the gap and shrank "
.. "the Pokemon back down at the very end of the grow")
end)()
-- ------- the pack cache must not evict a Pokemon that is standing there
--
-- The eviction order is keyed on LOADS, and a side only loads when its
+1 -1
View File
@@ -440,7 +440,7 @@ local function sweepSpecies(dex)
mon.rig, mon.model, mon.species = rig, model, dex
mon.state, mon.anim, mon.time = nil, nil, 0
for _, state in ipairs({ "idle", "entrance", "hit", "flinch", "faint" }) do
for _, state in ipairs({ "idle", "entrance", "attack", "faint" }) do
local ok, err = pcall(function()
mon:play(state)
-- a whole second of it at 60 Hz, which is what the fight does
+57
View File
@@ -0,0 +1,57 @@
-- Driver: the send-out ENTRANCE, frame by frame, for the two species the
-- animation QA sweep flags as flying apart in that animation alone.
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 lib = game.mods.exports.DRAMATIC_SHAPE.lib
local Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Install = lib.require("StadiumInstall")
game.save.player.name = "RED"
U.teleport(game, "ROUTE_1", 5, 8, "down")
local waited = 0
while (Install.pending() or Install.status.state == "building")
and waited < 5400 do U.wait(10); waited = waited + 10 end
while game.stack:top() and game.stack:top() ~= game.overworld do U.wait(10) end
Battles.setting:setValue(os.getenv("DS_RUNG") or "stadiumB", game)
Battles.backSetting:setValue(false, game)
lib.require("DayNight").setting:setValue("day", game)
U.wait(60)
for _, name in ipairs({ "FARFETCHD", "DEWGONG" }) do
game.save.party = { Pokemon.new(game.data, name, 40) }
local battle = BattleState.newWild(game, "PIKACHU", 20)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- ONE loop from the first frame: the send-out needs taps to get past
-- the intro text, and the entrance starts the instant it happens -- so
-- watching and tapping have to be the same loop or the animation is over
-- before the watching begins (which is exactly what a split loop did).
local shot, sawEntrance = 0, false
for f = 1, 900 do
U.wait(1)
local anim = Stadium.animOf("player")
if anim == "entrance" then
sawEntrance = true
if f % 14 == 0 and shot < 8 then
shot = shot + 1
U.shot(game, ("%s/%s_entrance_%d.png"):format(DIR, name:lower(), shot))
end
elseif sawEntrance then
break
end
if not sawEntrance and f % 12 == 0 then U.tap(game, "a") end
end
U.log(("%s: took %d entrance stills, anim now %s")
:format(name, shot, tostring(Stadium.animOf("player"))))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+84
View File
@@ -0,0 +1,84 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local lib = game.mods.exports.DRAMATIC_SHAPE.lib
local Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Install = lib.require("StadiumInstall")
U.teleport(game, "ROUTE_1", 5, 8, "down")
local w = 0
while (Install.pending() or Install.status.state == "building") and w < 5400 do
U.wait(10); w = w + 10
end
while game.stack:top() and game.stack:top() ~= game.overworld do U.wait(10) end
Battles.setting:setValue("stadium", game)
Battles.backSetting:setValue(false, game)
U.wait(60)
local marks = {}
local inner = BattleState.startGrowIn
BattleState.startGrowIn = function(self, b)
marks[#marks + 1] = { side = (b == self.player) and "player" or "enemy" }
return inner(self, b)
end
game.save.party = { Pokemon.new(game.data, "PIDGEY", 30) }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local seen, lastState, lastGrow, nMark = {}, "?", false, 0
local shotAt = {}
for f = 1, 500 do
U.wait(1)
if f % 10 == 0 and battle.phase ~= "menu" then U.tap(game, "a") end
local st = Stadium.animOf("player") or "-"
local grow = battle.growIn ~= nil
if #marks > nMark then
nMark = #marks
U.log(("f=%3d >> startGrowIn(%s) player model present: %s, state %s")
:format(f, marks[nMark].side, tostring(Stadium.showing("player")),
st))
end
local poof = (battle.animPlaying and battle.animName == "POOF_ANIM")
and true or false
local key = ("%s|%s|%s|%s|%s|%s|%s|%s|%s"):format(st,
tostring(math.floor((Stadium.scaleOf("player") or 1) * 20)),
tostring(poof), tostring(battle.sendingOut),
tostring(battle.phase), tostring(Stadium.showing("player")),
tostring(battle.showPlayerBack), tostring(battle.playerBackPic ~= nil),
tostring((battle.introSlide or 0) > 0))
if key ~= lastState then
U.log(("f=%3d state=%-9s sendingOut=%-5s poof=%-5s growIn=%-5s "
.. "growScale=%-6s model=%s")
:format(f, st, tostring(battle.sendingOut), tostring(poof),
tostring(grow), tostring(Stadium.scaleOf("player")),
tostring(Stadium.showing("player"))))
lastState = key
end
-- stills across the grow, so the ramp can be looked at and not just read
local sc = Stadium.scaleOf("player")
if Stadium.showing("player") and sc then
for _, want in ipairs({ 0.15, 0.45, 0.75, 1.0 }) do
if not shotAt[want] and sc >= want then
shotAt[want] = true
U.shot(game, ("%s/grow_%02d.png")
:format(os.getenv("SHOT_DIR") or ".", want * 100))
end
end
end
seen[st] = true
end
for i = 1, 4 do
U.shot(game, (os.getenv("SHOT_DIR") or ".") .. ("/pidgey_%d.png"):format(i))
U.wait(2)
end
local list = {}
for k in pairs(seen) do list[#list + 1] = k end
table.sort(list)
U.log("states the player's side passed through: " .. table.concat(list, ", "))
U.log("startGrowIn calls: " .. #marks)
U.log("done")
end
+1 -1
View File
@@ -92,7 +92,7 @@ import rom as rom_mod # noqa: E402
# 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',
'idle', 'attack_default', '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',