Files
DramaticShapeVoxelMod/tests/stadium_shots.lua
T
2026-08-04 12:30:10 -04:00

380 lines
16 KiB
Lua

-- Driver: screenshot a STADIUM battle -- the 3D-BTL row's third rung, where
-- the two Pokemon are the Pokemon Stadium battle models rather than the
-- game's own pics stood up on their tiles.
--
-- What has to be looked at is a SPREAD OF SPECIES, because almost everything
-- that can go wrong here is per-model: how big it comes out (the size ladder
-- compresses a sixteenfold spread of authored heights -- see StadiumMon),
-- whether its feet are on the ground (the floor rule has to tell a hovering
-- Zubat from a Tentacruel centred on its own origin), whether the eyes
-- animate, and whether the species carries generated flame prims. So the
-- list below is deliberately awkward: the smallest and the largest in the
-- set, a hoverer, a floater, and the two species whose idle animation is
-- known to be wrong in the source data.
--
-- SHOT_DIR=.scratchpad DS_STADIUM_DEBUG=1 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_shots.lua love .
--
-- SHOT_DIR must already exist: the capture writes with io.open, which does
-- not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Pack = lib.require("StadiumPack")
local Install = lib.require("StadiumInstall")
game.save.player.name = "RED"
-- foe, and the player's own, chosen to put two different problems in one
-- frame wherever possible
local CASES = {
{ "PIKACHU", "CHARIZARD" }, -- the reference pair; Charizard has flame
{ "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest
{ "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it
{ "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set
-- the two species whose animation data is corrupt at source: BOTH of
-- these must come out as flat battle pics standing on their tiles, not
-- as models (see StadiumMon.setSpecies). Tangela is the third.
{ "EXEGGUTOR", "MAGMAR" },
{ "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set
}
-- ONTO THE MAP FIRST, before anything below waits on anything.
--
-- The models are built out of the player's own ROM on first run, on a
-- loading screen, and that screen is pushed from the mod's update hook on
-- the first frame THE OVERWORLD IS THE TOP STATE (StadiumScreen.maybePush).
-- A driver that waits for the build before it has got the player onto a map
-- waits forever: the condition that starts the build is the very thing the
-- waiting is postponing.
-- DS_MAP puts the fight somewhere else. It exists for STADIUM B, whose
-- whole claim is that it carries its stage: the shot has to be checked in a
-- CAVE and in a SHOP as well as on a route, both because those are the
-- places the map-staged rungs decline, and because the rung is supposed to
-- take that place's own sky and light with it.
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local MX = tonumber(os.getenv("DS_X") or "") or 5
local MY = tonumber(os.getenv("DS_Y") or "") or 8
U.teleport(game, MAP, MX, MY, "down")
-- Now wait it out, and say how long it took -- that number is what decides
-- whether the loading screen is acceptable rather than merely correct.
local waited, shotLoad = 0, false
while (Install.pending() or Install.status.state == "building")
and waited < 5400 do
if not shotLoad and Install.status.state == "building"
and (Install.status.done or 0) > 20 then
U.shot(game, ("%s/00_loading.png"):format(DIR))
shotLoad = true
end
U.wait(10)
waited = waited + 10
end
if waited > 0 then
U.log(("waited %d frames (%.1fs) for the model build -- %s")
:format(waited, waited / 60, tostring(Install.status.state)))
if Install.status.error then
U.log("build error: " .. tostring(Install.status.error))
end
end
U.log(("rom present: %s, packs ready: %s, stadium available: %s")
:format(tostring(Install.romPresent()), tostring(Install.ready()),
tostring(Install.available())))
-- the rung under test, and the back pic OFF: BACK SPRITES would keep the
-- player's own side on the menu, which is exactly the half of the shot
-- this driver exists to look at
local RUNGS = { cards = true, cardsb = "flatB", a = "stadium",
b = "stadiumB" }
local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium"
Battles.setting:setValue(rung, game)
Battles.backSetting:setValue(false, game)
-- and PIN THE CLOCK. The hour multiplies everything in the shot -- the sky,
-- the tint, the weight of the shadows -- so a driver left on CYCLE
-- photographs a different scene every run, and two runs of it cannot be
-- compared. DS_TOD overrides for a deliberate look at dusk or night.
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
U.log(("3D-BTL = %s, stadium enabled = %s, discs = %s")
:format(tostring(Battles.setting:get()), tostring(Stadium.enabled()),
tostring(Stadium.discs())))
-- did the models actually get built? one line rather than 151 silent
-- declines
local have, missing = 0, {}
for dex = 1, 151 do
if Pack.available(dex) then have = have + 1
elseif #missing < 6 then missing[#missing + 1] = dex end
end
U.log(("stadium packs present: %d/151%s"):format(
have, #missing > 0 and (" (missing " .. table.concat(missing, ",")
.. "...)") or ""))
-- let the neighbourhood's meshes land before the first fight, so the
-- opening frame is not the flat fallback
U.wait(90)
-- ------- DS_BLINK: how often does the eye actually change?
--
-- The texture animation rides the SKELETAL animation's frame and clamps
-- past the end of its own stream (StadiumRig.textures). Get either wrong
-- and a blink becomes a twitch: Rattata's standby loop is forty frames and
-- its blink is five, so wrapping on the blink's own length plays it six
-- times a second.
--
-- Counted rather than looked at, because "too fast" is a RATE and a
-- screenshot has no rate in it. This walks the idle loop frame by frame and
-- reports how many times the eye texture changes per second.
if os.getenv("DS_BLINK") then
local Pack = lib.require("StadiumPack")
local Rig = lib.require("StadiumRig")
for _, dex in ipairs({ 19, 4, 1, 25 }) do
local model = Pack.load(dex)
local rig = model and Rig.new(model)
if rig then
local idle = model.ctx[Pack.SLOT.idle]
local anim = (idle ~= Pack.NONE) and (idle + 1) or nil
local rec = anim and model.anims[anim]
-- which prim carries the eye, and what it shows each frame
local seen, changes, last = {}, 0, nil
local SECONDS = 4
for f = 0, SECONDS * Pack.FPS - 1 do
rig:pose(anim, f, true)
rig:textures(rec and rec.aux or nil)
local key = {}
for i, part in ipairs(rig.parts) do
if part.prim.texAnim and part.prim.texAnim >= 0 then
key[#key + 1] = i .. ":" .. tostring(part.texture)
end
end
key = table.concat(key, ",")
if last and key ~= last then changes = changes + 1 end
last = key
seen[key] = true
end
local looks = 0
for _ in pairs(seen) do looks = looks + 1 end
local period = rec and (rec.frames / Pack.FPS) or 0
U.log(("BLINK dex %3d: idle %s frames (%.2fs), aux %s frames -- "
.. "%d texture changes in %ds, %d distinct looks; the idle "
.. "loop comes round every %.2fs, so that is %.1f changes a "
.. "cycle")
:format(dex, tostring(rec and rec.frames), period,
tostring(rec and rec.aux
and model.auxAnims[rec.aux].frames),
changes, SECONDS, looks, period,
period > 0 and changes / (SECONDS / period) or 0))
rig:release()
end
end
U.log("done -- " .. DIR)
return
end
-- ------- DS_FLY: does the Pokemon actually leave?
--
-- FLY and DIG take the user off the field for a turn, and the engine says
-- so through `picFx[battler].hidden` rather than through fxHidden (which is
-- the damage blink alone). A model that ignores that stands on its tile
-- while every attack aimed at it misses into empty air.
--
-- A TIMELINE again, not a still: what is being checked is that the model
-- goes when the pic goes and comes back when it comes back, which is three
-- moments and a screenshot has one.
if os.getenv("DS_FLY") then
local Stadium2 = lib.require("Stadium")
local move = os.getenv("DS_MOVE") or "FLY"
local mine = Pokemon.new(game.data, "CHARIZARD", 60)
-- give it the move outright: what is under test is the vanish, not
-- whether this species learns it by level 60
mine.moves = { { id = move, pp = 15 } }
game.save.party = { mine }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.tap(game, "a") -- FIGHT
U.wait(12)
U.tap(game, "a") -- the first (only) move
local goneAt, backAt, wasGone = nil, nil, false
for f = 1, 400 do
if f % 20 == 0 then U.tap(game, "a") end
U.wait(1)
local me = battle.player
local pf = battle.picFx and battle.picFx[me]
local picHidden = (pf and pf.hidden) and true or false
local showing = Stadium2.showing("player")
if f % 25 == 0 or (picHidden ~= wasGone) then
U.log((" f=%3d picHidden=%-5s model=%-5s anim=%s")
:format(f, tostring(picHidden), tostring(showing),
tostring(Stadium2.animOf("player"))))
end
if picHidden and not goneAt then goneAt = f end
if picHidden and not showing and not wasGone then
wasGone = true
U.shot(game, ("%s/fly_1_gone.png"):format(DIR))
end
if wasGone and not picHidden and not backAt then
backAt = f
U.shot(game, ("%s/fly_2_back.png"):format(DIR))
break
end
end
U.log(("%s: the pic hid at frame %s, the model was gone with it (%s), "
.. "and both came back at %s")
:format(move, tostring(goneAt), tostring(wasGone), tostring(backAt)))
U.log(wasGone and " OK -- the model leaves the field with the pic"
or " WRONG -- the model stood there while the game said it was gone")
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
U.log("done -- " .. DIR)
return
end
-- ------- DS_FAINT: does the collapse wait for the bar?
--
-- The faint animation must not start until the foe's HP bar has finished
-- emptying: `onFaint` fires the instant HP reaches zero, but the engine
-- queues the visible collapse behind the move animation and the drain, and
-- a Pokemon that lies down while its own health is still draining above it
-- reads as a bug (see Stadium's faintReady).
--
-- A TIMELINE rather than a screenshot, because what is being checked is an
-- ORDER: the frame the bar reaches zero against the frame the animation
-- changes. One printed line per frame settles that; two stills cannot.
if os.getenv("DS_FAINT") then
local Stadium2 = lib.require("Stadium")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
local barZeroAt, animAt, shot, stood = nil, nil, false, 0
for f = 1, 700 do
-- keep advancing the text, as a player would. Without this the queue
-- blocks on the damage message and the HP bar never drains at all --
-- which is a perfectly good demonstration that the collapse now waits,
-- and no demonstration whatever that it eventually arrives.
if f % 20 == 0 then U.tap(game, "a") end
U.wait(1)
local foe = battle.enemy
local shown = foe and foe.shownHP
local anim = Stadium2.animOf and Stadium2.animOf("enemy") or nil
if f % 25 == 0 or (shown and shown <= 0 and not barZeroAt) then
U.log((" f=%3d phase=%s shownHP=%s hp=%s queued=%s anim=%s")
:format(f, tostring(battle.phase), tostring(shown),
tostring(foe and foe.mon and foe.mon.hp),
tostring(foe and foe.faintQueued), tostring(anim)))
end
if shown and shown <= 0 and not barZeroAt then barZeroAt = f end
if anim == "faint" and not animAt then animAt = f end
-- a still from the middle of the drain: the foe must still be standing
if shown and shown > 0 and not shot and barZeroAt == nil
and foe and foe.faintQueued then
U.shot(game, ("%s/faint_1_draining.png"):format(DIR))
shot = true
end
-- and how long the model then STAYS. The engine takes the flat pic off
-- after a fourteen-frame slide; the animation runs for one to eight
-- seconds, and being cut off mid-fall is worse than not falling at all.
if animAt and Stadium2.showing("enemy") then stood = f - animAt end
if animAt and f == animAt + 20 then
U.shot(game, ("%s/faint_2_collapsing.png"):format(DIR))
end
if animAt and f == animAt + 90 then
U.shot(game, ("%s/faint_3_still_falling.png"):format(DIR))
end
if animAt and f > animAt + 20 and not Stadium2.showing("enemy") then
U.shot(game, ("%s/faint_4_gone.png"):format(DIR))
break
end
end
U.log(("FAINT: bar reached zero at frame %s, faint animation began at %s")
:format(tostring(barZeroAt), tostring(animAt)))
if barZeroAt and animAt then
U.log((animAt >= barZeroAt)
and " OK -- the collapse waits for the bar"
or " WRONG -- the collapse ran while the bar was still draining")
end
U.log((" the model stayed on the field for %d frames (%.2fs) after the "
.. "collapse began; the engine's own pic slide is 14 (%.2fs)")
:format(stood, stood / 60, 14 / 60))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
U.log("done -- " .. DIR)
return
end
for i, case in ipairs(CASES) do
local foe, mine = case[1], case[2]
local tag = ("%02d_%s_vs_%s"):format(i, mine:lower(), foe:lower())
game.save.party = { Pokemon.new(game.data, mine, 40) }
local battle = BattleState.newWild(game, foe, 30)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- The wipe, then the intro chatter, and -- the part that matters --
-- far enough past it that the player's own Pokemon has been SENT OUT.
-- Before that the player's side is legitimately the trainer's back
-- sprite, with no Pokemon on the field to model at all; a shot taken
-- there looks exactly like a bug and is not one.
U.wait(70)
-- tap until the FIGHT menu is up, which is the first moment BOTH
-- Pokemon are on the field in their standby loop
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.wait(45)
U.shot(game, ("%s/%s_1_idle.png"):format(DIR, tag))
-- FIGHT -> the first move, which is where the attack animation is
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(22)
U.shot(game, ("%s/%s_2_attack.png"):format(DIR, tag))
U.wait(45)
U.shot(game, ("%s/%s_3_hit.png"):format(DIR, tag))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end