-- 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