mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
371 lines
15 KiB
Lua
371 lines
15 KiB
Lua
-- Probe: the Gold battle SCREEN, one reported symptom per scenario.
|
|
--
|
|
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_screen_probe.lua \
|
|
-- POKEPORT_PROBE=catch POKEPORT_SHOT_DIR=/tmp/gold-battle-screen love .
|
|
--
|
|
-- POKEPORT_PROBE picks the scenario (comma separated, default `catch`):
|
|
--
|
|
-- catch a wild mon is caught: the throw animation runs (the ball's own Y
|
|
-- is counted, so "it moved" is a number), and the mon's frontpic is
|
|
-- GONE from the box for every line after it
|
|
-- boxfull the same catch with six in the party: it lands in a real box.
|
|
-- POKEPORT_PROBE_BOX sets save.currentBox first -- 0 is the value a
|
|
-- save converted off a cartridge carries, and the one that used to
|
|
-- drop the catch on the floor
|
|
-- faint a player mon faints in a trainer battle: the fainted pic sinks
|
|
-- out of its box before the line, and picking the fainted mon in
|
|
-- the forced list is REFUSED out loud instead of silently
|
|
-- tutorial the DUDE's demonstration, start to finish, on its own auto-input
|
|
-- trainer the trainer's own frontpic stands in the enemy box for the intro
|
|
-- (needs a cache with menu_gfx battleHud.trainerPics; an older
|
|
-- import has none and the mon stands in for the whole intro)
|
|
-- scale the battle_sprite_scales reader, planted straight on data
|
|
--
|
|
-- Every scenario shoots, because the answer to most of these is a picture.
|
|
local U = require("tests.drivers.util")
|
|
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
|
|
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-screen"
|
|
|
|
-- main.lua's love.visible forwards to Game:visible, which the Gen 2 Game
|
|
-- object does not have, so another window taking focus mid-run kills the whole
|
|
-- driver with "attempt to call method 'visible'". It fires during the cache
|
|
-- mount, before the first frame this driver is resumed on, which is why the
|
|
-- guard is at module scope: loadfile runs it inside love.load. Nothing this
|
|
-- probe is about, so the callback is let through under pcall rather than
|
|
-- costing a 200-frame run.
|
|
local hostVisible = love.visible
|
|
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
|
|
|
|
local function tap(game, button, frames)
|
|
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
|
game.input.state[button] = true
|
|
U.wait(2)
|
|
game.input.state[button] = false
|
|
U.wait(frames or 6)
|
|
end
|
|
|
|
-- The battle screen, once the transition has handed the stack over.
|
|
local function openBattle(game, opts)
|
|
assert(game.world:startBattle(opts), "startBattle failed")
|
|
for _ = 1, 900 do
|
|
local top = game.stack:top()
|
|
if top and top.battle then return top end
|
|
U.wait(1)
|
|
end
|
|
error("battle screen never came up")
|
|
end
|
|
|
|
-- Page the intro (slide, "appeared!", the send-out) until the menu is up.
|
|
local function toMenu(game, battle, limit)
|
|
for _ = 1, (limit or 400) do
|
|
if battle.phase == "menu" then return true end
|
|
if battle.battle.over then return false end
|
|
tap(game, "a", 2)
|
|
end
|
|
return battle.phase == "menu"
|
|
end
|
|
|
|
-- Shoots an animation while it runs, and tracks the OBJ layer's first sprite so
|
|
-- "the ball moved" is a number rather than a squint: BattleAnim_ThrowPokeBall's
|
|
-- own bounce and its three shakes are all Y motion on that one object.
|
|
local function shotsWhileAnim(game, battle, prefix, every)
|
|
local frames, moves, lastY, minY, maxY = 0, 0, nil, nil, nil
|
|
while battle.anim and frames < 600 do
|
|
if frames % (every or 4) == 0 then
|
|
U.shot(game, ("%s-%03d.png"):format(prefix, frames))
|
|
end
|
|
local obj = battle.anim:oam()[1]
|
|
if obj then
|
|
if lastY and obj.y ~= lastY then moves = moves + 1 end
|
|
lastY = obj.y
|
|
minY = math.min(minY or obj.y, obj.y)
|
|
maxY = math.max(maxY or obj.y, obj.y)
|
|
end
|
|
frames = frames + 1
|
|
U.wait(1)
|
|
end
|
|
return frames, moves, minY, maxY
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
|
|
local Probes = {}
|
|
|
|
-- 1, 5, 7: the throw animation, and the pic that must not come back.
|
|
function Probes.catch(game)
|
|
local world = game.world
|
|
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
|
|
game.save.inventory = { POKE_BALL = 10, MASTER_BALL = 5 }
|
|
game.save.boxes = nil
|
|
local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) })
|
|
assert(toMenu(game, battle), "never reached the battle menu")
|
|
U.shot(game, OUT .. "/catch-00-menu.png")
|
|
|
|
battle:useItem("MASTER_BALL")
|
|
print(("[probe] catch: anim=%s ballThrow.caught=%s"):format(
|
|
tostring(battle.anim ~= nil),
|
|
tostring(battle.ballThrow and battle.ballThrow.caught)))
|
|
local frames, moves, minY, maxY =
|
|
shotsWhileAnim(game, battle, OUT .. "/catch-01-throw", 4)
|
|
print(("[probe] catch: throw animation ran %d frames, ball moved on %d of"
|
|
.. " them, y %s..%s"):format(frames, moves, tostring(minY), tostring(maxY)))
|
|
|
|
-- The moment the animation lets go of the screen: the mon went into the
|
|
-- ball, so nothing may be standing in the enemy box here or on any of the
|
|
-- lines that follow.
|
|
for i = 0, 5 do
|
|
U.shot(game, ("%s/catch-01b-after-anim-%d.png"):format(OUT, i))
|
|
tap(game, "a", 6)
|
|
end
|
|
|
|
-- Page the caught text, the dex line and the nickname prompt (NO).
|
|
for _ = 1, 200 do
|
|
if battle.phase == "done" or not game.stack:top() then break end
|
|
if battle.phase == "ask-nickname" then
|
|
tap(game, "b", 4)
|
|
else
|
|
tap(game, "a", 3)
|
|
end
|
|
if battle.phase == "resolving" and #battle.queue == 0
|
|
and battle.message == nil then
|
|
break
|
|
end
|
|
end
|
|
U.shot(game, OUT .. "/catch-02-after.png")
|
|
print(("[probe] catch: outcome=%s party=%d picHidden=%s"):format(
|
|
tostring(battle.battle.outcome), #game.save.party,
|
|
tostring(battle.picHidden and battle.picHidden.enemy)))
|
|
print(("[probe] catch: enemy pic still resolvable: %s"):format(
|
|
tostring(battle:pic(battle:activeMon("enemy"), false) ~= nil)))
|
|
end
|
|
|
|
-- 3: six in the party sends the catch to the box.
|
|
function Probes.boxfull(game)
|
|
local party = {}
|
|
for _ = 1, 6 do party[#party + 1] = Mon.new(game.data, "CYNDAQUIL", 30) end
|
|
game.save.party = party
|
|
game.save.boxes = nil
|
|
-- wCurBox is 0-based on the cart (box 0 is BOX 1), and a save converted out
|
|
-- of a real cartridge carries that byte through unchanged -- so this is a
|
|
-- currentBox a live save really can hold, and the arm has to survive it.
|
|
game.save.currentBox = tonumber(os.getenv("POKEPORT_PROBE_BOX") or "1")
|
|
game.save.inventory = { MASTER_BALL = 5 }
|
|
local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) })
|
|
assert(toMenu(game, battle), "never reached the battle menu")
|
|
battle:useItem("MASTER_BALL")
|
|
shotsWhileAnim(game, battle, OUT .. "/box-01-throw", 8)
|
|
for _ = 1, 200 do
|
|
if battle.phase == "ask-nickname" then tap(game, "b", 4)
|
|
else tap(game, "a", 3) end
|
|
if battle.phase == "done" or not game.stack:top() then break end
|
|
end
|
|
local total = 0
|
|
for _, box in pairs(game.save.boxes or {}) do total = total + #box end
|
|
local box = (game.save.boxes or {})[1] or {}
|
|
print(("[probe] boxfull: currentBox=%s party=%d box1=%d anywhere=%d first=%s")
|
|
:format(tostring(game.save.currentBox), #game.save.party, #box, total,
|
|
tostring(box[1] and box[1].species)))
|
|
U.shot(game, OUT .. "/box-02-after.png")
|
|
end
|
|
|
|
-- 4 and 6: the faint slide, and the forced switch that has to take first try.
|
|
function Probes.faint(game)
|
|
local world = game.world
|
|
local weak = Mon.new(game.data, "CYNDAQUIL", 5)
|
|
weak.hp = 1
|
|
local strong = Mon.new(game.data, "TOTODILE", 30)
|
|
game.save.party = { weak, strong }
|
|
game.save.inventory = {}
|
|
local entry = world:trainerParty(36, 1) -- BUG_CATCHER member 1
|
|
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
|
|
local Trainers = require("src.world.gen2.Trainers")
|
|
entry.party = Trainers.party(game.data, entry)
|
|
local battle = openBattle(game, { trainer = entry })
|
|
assert(toMenu(game, battle), "never reached the battle menu")
|
|
|
|
-- Count how many times the party list is opened for the forced switch.
|
|
local opens = 0
|
|
local realOpen = battle.openParty
|
|
battle.openParty = function(self, forced)
|
|
if forced then opens = opens + 1 end
|
|
return realOpen(self, forced)
|
|
end
|
|
|
|
-- What a player does: press A on the row the cursor is already on. Row 1 is
|
|
-- the mon that just fainted, so the first two picks are the refusal the cart
|
|
-- answers with "There's no will to fight!"; only the third moves down.
|
|
local picks, refusedMessages = 0, {}
|
|
local shot, sawFaint = 0, false
|
|
for _ = 1, 900 do
|
|
if battle.battle.over then break end
|
|
local top = game.stack:top()
|
|
if top ~= battle then
|
|
picks = picks + 1
|
|
if picks > 2 then tap(game, "down", 3) end
|
|
tap(game, "a", 4)
|
|
if battle.message then
|
|
refusedMessages[#refusedMessages + 1] = battle.message
|
|
end
|
|
elseif battle.faintSlide then
|
|
-- MonFaintedAnimation is running: one shot a frame, because the whole
|
|
-- claim is that the pic sinks out of its box before the line goes up.
|
|
U.shot(game, ("%s/faint-slide-%s-%02d.png"):format(OUT,
|
|
battle.faintSlide.side, battle.faintSlide.frames))
|
|
U.wait(1)
|
|
elseif battle.message and battle.message:match("fainted") then
|
|
if not sawFaint then
|
|
sawFaint = true
|
|
print("[probe] faint: line up -- " .. battle.message)
|
|
end
|
|
if shot < 12 then
|
|
U.shot(game, ("%s/faint-%02d.png"):format(OUT, shot))
|
|
shot = shot + 1
|
|
U.wait(2)
|
|
else
|
|
tap(game, "a", 2)
|
|
end
|
|
elseif battle.phase == "menu" then
|
|
tap(game, "a", 2) -- FIGHT
|
|
U.wait(3)
|
|
tap(game, "a", 2) -- first move
|
|
else
|
|
tap(game, "a", 2)
|
|
end
|
|
end
|
|
print(("[probe] faint: list opened %d time(s) for %d pick(s), outcome=%s")
|
|
:format(opens, picks, tostring(battle.battle.outcome)))
|
|
print(("[probe] faint: player is now %s (party slot 2 is %s)"):format(
|
|
tostring(battle.battle.player and battle.battle.player.species),
|
|
tostring(game.save.party[2] and game.save.party[2].species)))
|
|
for i, text in ipairs(refusedMessages) do
|
|
print(("[probe] faint: after pick %d the box said %q"):format(i, text))
|
|
end
|
|
end
|
|
|
|
-- 5 and 7: the DUDE's demonstration, which throws the same ball through the
|
|
-- same animation and then has to leave the RATTATA inside it.
|
|
function Probes.tutorial(game)
|
|
local world = game.world
|
|
game.save.party = {}
|
|
local rattata = game.data.pokemon.RATTATA
|
|
local battle
|
|
-- Script_catchtutorial arms CATCH_TUTORIAL around StartBattle and the DUDE's
|
|
-- own presses are RE-ARMS of that ring (CatchTutorial.rearm refuses a ring
|
|
-- that is not already active), so the stream has to be started here the way
|
|
-- the VM starts it -- without it the demo hangs on its first prompt forever.
|
|
game.autoInput:start("CATCH_TUTORIAL", game.input)
|
|
world:startCatchTutorial({ species = rattata.index, level = 5 }, nil,
|
|
function()
|
|
game.autoInput:stop(game.input)
|
|
print("[probe] tutorial: battle closed")
|
|
end)
|
|
for _ = 1, 900 do
|
|
local top = game.stack:top()
|
|
if top and top.battle then battle = top break end
|
|
U.wait(1)
|
|
end
|
|
assert(battle, "the tutorial battle never came up")
|
|
-- The DUDE plays it himself; all this does is shoot and stay out of the way.
|
|
-- The loop watches the battle SCREEN rather than the stack top, because the
|
|
-- demo opens the pack over it and a top-of-stack test would stop counting
|
|
-- exactly where the throw happens.
|
|
local shot, sawAnim, animFrames = 0, false, 0
|
|
for _ = 1, 2000 do
|
|
if battle.phase == "done" then break end
|
|
if battle.anim then sawAnim = true animFrames = animFrames + 1 end
|
|
if shot % 8 == 0 then
|
|
U.shot(game, ("%s/tutorial-%03d.png"):format(OUT, shot))
|
|
end
|
|
shot = shot + 1
|
|
U.wait(1)
|
|
end
|
|
print(("[probe] tutorial: ball animation seen=%s (%d frames), shots=%d")
|
|
:format(tostring(sawAnim), animFrames, shot))
|
|
end
|
|
|
|
-- The battle_sprite_scales registry, whose records are keyed by ASSET PATH and
|
|
-- are the only handle on a pic that is not a species' own. Schemas.GEN2 still
|
|
-- routes the registry nowhere, so the Loader drops a mod's registration with a
|
|
-- warning; the record is planted straight on data here to show the READER is
|
|
-- live, which is the half that has to exist before the row can be un-gated.
|
|
function Probes.scale(game)
|
|
game.data.battle_sprite_scales = game.data.battle_sprite_scales or {}
|
|
game.data.battle_sprite_scales.probe_back = {
|
|
path = "assets/generated/battle/back/cyndaquil_back.png", scale = 0.5,
|
|
}
|
|
game.data.battle_sprite_scales.probe_front = {
|
|
path = "assets/generated/battle/front/pidgey.png", scale = 1.5,
|
|
}
|
|
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
|
|
local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) })
|
|
assert(toMenu(game, battle), "never reached the battle menu")
|
|
U.shot(game, OUT .. "/scale-00-menu.png")
|
|
print(("[probe] scale: back=%s front=%s (1 means the reader is not wired)")
|
|
:format(
|
|
tostring(battle:picScale(
|
|
"assets/generated/battle/back/cyndaquil_back.png", nil, true)),
|
|
tostring(battle:picScale(
|
|
"assets/generated/battle/front/pidgey.png", nil, false))))
|
|
game.data.battle_sprite_scales.probe_back = nil
|
|
game.data.battle_sprite_scales.probe_front = nil
|
|
end
|
|
|
|
-- 2: the trainer's frontpic during the intro.
|
|
function Probes.trainer(game)
|
|
local world = game.world
|
|
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
|
|
local entry = world:trainerParty(36, 1)
|
|
assert(entry, "no BUG_CATCHER member 1")
|
|
local Trainers = require("src.world.gen2.Trainers")
|
|
entry.party = Trainers.party(game.data, entry)
|
|
local hud = game.data.gen2MenuGfx and game.data.gen2MenuGfx.battleHud
|
|
local pics = hud and hud.trainerPics
|
|
print(("[probe] trainer: cache trainerPics=%s entry.class=%s classId=%s"
|
|
.. " className=%s"):format(
|
|
tostring(pics and "yes" or "no"), tostring(entry.class),
|
|
tostring(entry.classId), tostring(entry.className)))
|
|
local battle = openBattle(game, { trainer = entry })
|
|
print(("[probe] trainer: showEnemyTrainer=%s image=%s class=%s"):format(
|
|
tostring(battle.showEnemyTrainer), tostring(battle.enemyTrainerImage ~= nil),
|
|
tostring(battle.enemyTrainerClass)))
|
|
-- The intro slide, the "wants to battle!" line, then the pic sliding out.
|
|
U.wait(40)
|
|
U.shot(game, OUT .. "/trainer-00-slide.png")
|
|
U.wait(45)
|
|
U.shot(game, OUT .. "/trainer-01-intro.png")
|
|
for _ = 1, 12 do
|
|
tap(game, "a", 2)
|
|
if battle.trainerSlide then break end
|
|
end
|
|
U.shot(game, OUT .. "/trainer-02-slide-out.png")
|
|
U.wait(20)
|
|
U.shot(game, OUT .. "/trainer-03-mon.png")
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
|
|
return function(game)
|
|
U.wait(45)
|
|
assert(game.world and game.world.map, "gold world did not boot")
|
|
|
|
local wanted = os.getenv("POKEPORT_PROBE") or "catch"
|
|
for name in wanted:gmatch("[%w_]+") do
|
|
local probe = Probes[name]
|
|
if not probe then
|
|
print("[probe] no scenario named " .. name)
|
|
else
|
|
print("[probe] ---- " .. name)
|
|
probe(game)
|
|
-- Back to the overworld before the next scenario.
|
|
for _ = 1, 300 do
|
|
if game.stack:top() == nil then break end
|
|
tap(game, "a", 2)
|
|
end
|
|
end
|
|
end
|
|
print("[probe] done, shots in " .. OUT)
|
|
love.event.quit()
|
|
end
|