From b7ce0f21d513848213b8ac3d69fab4afd0881f8c Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Tue, 4 Aug 2026 14:18:11 -0400 Subject: [PATCH] enforce 1.0 stadium rom --- CHANGELOG.md | 93 +++++++++++-- README.md | 65 +++++++-- lib/BattleHud.lua | 217 +++---------------------------- lib/OverworldBattle.lua | 58 ++------- lib/Stadium.lua | 83 +++++++++--- lib/StadiumBuild.lua | 5 +- lib/StadiumInstall.lua | 23 +++- lib/StadiumMon.lua | 104 +++++++++++++-- lib/StadiumPack.lua | 7 +- lib/StadiumRomPick.lua | 4 +- lib/StadiumScreen.lua | 32 +++-- main.lua | 6 +- manifest.json | 2 +- mod.card | 6 +- model_extract/README.md | 13 ++ tests/dramatic_shape_test.lua | 52 ++++++++ tests/stadium_anim_qa.lua | 2 +- tests/stadium_entrance_shots.lua | 57 ++++++++ tests/stadium_entry_probe.lua | 84 ++++++++++++ tools/stadium_pack.py | 2 +- 20 files changed, 591 insertions(+), 324 deletions(-) create mode 100644 tests/stadium_entrance_shots.lua create mode 100644 tests/stadium_entry_probe.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ec494..6edd0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f8aace7..addf73c 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +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). \ No newline at end of file diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index 34432e7..776af0f 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -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 diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f4155d5..7e29167 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -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 diff --git a/lib/Stadium.lua b/lib/Stadium.lua index 64a80d9..aceeab9 100644 --- a/lib/Stadium.lua +++ b/lib/Stadium.lua @@ -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 diff --git a/lib/StadiumBuild.lua b/lib/StadiumBuild.lua index c84b479..44a1fd5 100644 --- a/lib/StadiumBuild.lua +++ b/lib/StadiumBuild.lua @@ -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 diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua index a161b40..3c25c9b 100644 --- a/lib/StadiumInstall.lua +++ b/lib/StadiumInstall.lua @@ -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) diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua index a4fb1e4..25030e9 100644 --- a/lib/StadiumMon.lua +++ b/lib/StadiumMon.lua @@ -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 diff --git a/lib/StadiumPack.lua b/lib/StadiumPack.lua index 41ebe4b..f8ce3b9 100644 --- a/lib/StadiumPack.lua +++ b/lib/StadiumPack.lua @@ -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", diff --git a/lib/StadiumRomPick.lua b/lib/StadiumRomPick.lua index 068221a..6eea946 100644 --- a/lib/StadiumRomPick.lua +++ b/lib/StadiumRomPick.lua @@ -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 -- diff --git a/lib/StadiumScreen.lua b/lib/StadiumScreen.lua index 9ea6c94..dd44076 100644 --- a/lib/StadiumScreen.lua +++ b/lib/StadiumScreen.lua @@ -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 diff --git a/main.lua b/main.lua index 6b9e33e..8cf1045 100644 --- a/main.lua +++ b/main.lua @@ -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, diff --git a/manifest.json b/manifest.json index 93c8f8c..8ddd77d 100644 --- a/manifest.json +++ b/manifest.json @@ -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" } diff --git a/mod.card b/mod.card index cb472c3..93e0c27 100644 --- a/mod.card +++ b/mod.card @@ -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 }, diff --git a/model_extract/README.md b/model_extract/README.md index 061e3d1..922b626 100644 --- a/model_extract/README.md +++ b/model_extract/README.md @@ -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: diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 4be35b8..04a4522 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -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 diff --git a/tests/stadium_anim_qa.lua b/tests/stadium_anim_qa.lua index afc2fcd..3a4aac2 100644 --- a/tests/stadium_anim_qa.lua +++ b/tests/stadium_anim_qa.lua @@ -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 diff --git a/tests/stadium_entrance_shots.lua b/tests/stadium_entrance_shots.lua new file mode 100644 index 0000000..b195c6d --- /dev/null +++ b/tests/stadium_entrance_shots.lua @@ -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 diff --git a/tests/stadium_entry_probe.lua b/tests/stadium_entry_probe.lua new file mode 100644 index 0000000..6bb5fea --- /dev/null +++ b/tests/stadium_entry_probe.lua @@ -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 diff --git a/tools/stadium_pack.py b/tools/stadium_pack.py index 5e762ab..9bd2e85 100644 --- a/tools/stadium_pack.py +++ b/tools/stadium_pack.py @@ -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',