Merge remote-tracking branch 'origin/dev' into feat/mod-pokemon-icon

# Conflicts:
#	docs/modding.md
This commit is contained in:
MaxTomahawk
2026-08-12 09:46:44 +02:00
540 changed files with 231032 additions and 1896 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
-- Contact sheet: the Gen 2 battle-animation runtime, frame by frame.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_anim_shots.lua love .
--
-- Shoots the 72-frame intro slide and then a run of move animations, one shot
-- every few frames, into /tmp/gold-anims. This is the only way to check the
-- object functions: they are pure arithmetic on byte fields and a test can say
-- "the struct moved", but only a picture says the flame went the right way.
--
-- POKEPORT_ANIM_MOVES=TACKLE,EMBER picks the moves; the default set covers one
-- animation from each family the runtime has to get right (a straight throw,
-- a spiral, a screen shake, a per-scanline sink, a palette cycle).
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local DEFAULT_MOVES = {
"TACKLE", "EMBER", "WATER_GUN", "THUNDERSHOCK", "RAZOR_LEAF",
"EARTHQUAKE", "WITHDRAW", "DIG", "SING", "ABSORB",
-- The screen-wide deformations, which were no-ops until the sixth pass:
-- rolling water, a warp, an afterimage and a melt.
"SURF", "WHIRLPOOL", "PSYCHIC_M", "TELEPORT", "NIGHT_SHADE",
"DOUBLE_TEAM", "ACID_ARMOR",
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-anims"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "4")
local moves = {}
local requested = os.getenv("POKEPORT_ANIM_MOVES")
if requested then
for name in requested:gmatch("[^,]+") do moves[#moves + 1] = name end
else
moves = DEFAULT_MOVES
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local player = Mon.new(game.data, "CYNDAQUIL", 30)
assert(player, "could not build a CYNDAQUIL")
game.save.party = { player }
local wild = Mon.new(game.data, "PIDGEY", 30)
assert(world:startBattle({ wild = wild }), "startBattle failed")
-- DoBattleTransition owns the screen first now; wait it out.
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
assert(battle and battle.battle, "battle screen is not on the stack")
assert(battle.anims and battle.anims.scripts,
"battle_anims.lua has no scripts -- re-import Gold")
-- The intro slide, which runs before any input is read.
for frame = 0, 72, 6 do
U.shot(game, ("%s/00-slide-%02d.png"):format(out, frame))
U.wait(6)
end
-- Then each move's own animation, started directly rather than through the
-- menu so the shot numbering stays predictable.
local missing = {}
for index, move in ipairs(moves) do
battle.anim = nil
if not battle:animForMove(move, "player") then
missing[#missing + 1] = move
else
-- BattleState:update steps the runner itself, so the driver only waits
-- and shoots; stepping here too would run it at double speed.
local shot = 0
while battle.anim and shot < 400 do
if shot % interval == 0 then
U.shot(game, ("%s/%02d-%s-%03d.png"):format(out, index, move, shot))
end
shot = shot + 1
U.wait(1)
end
assert(shot < 400, move .. " never finished")
print(("[driver] %-14s %d frames"):format(move, shot))
end
end
if #missing > 0 then
print("[driver] no animation for: " .. table.concat(missing, ", "))
end
print("[driver] PASS gold battle anims in " .. out)
love.event.quit()
end
+151
View File
@@ -0,0 +1,151 @@
-- Assertion driver: the PACK inside a real battle, driven with button taps.
-- It PASSES or it errors; there is nothing to eyeball.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_battle_items.lua love .
--
-- tests/gen2_battle_items_test.lua proves each item_effects.asm family over
-- fixtures by calling BattleState:useItem; what it cannot prove is the link in
-- front of it -- BattleMenu's PACK row opening the real pack over a real
-- battle, the real "Use on which <PK><MN>?" list on top of that, and the pick
-- landing on a BENCHED mon. So this revives a fainted party member from
-- inside a wild battle with nothing but taps (ReviveEffect through
-- UseItem_SelectMon), then spends an ETHER through the move list
-- (RestorePPEffect's MoveSelectionScreen pick).
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
local lead = Mon.new(game.data, "CYNDAQUIL", 12)
local bench = Mon.new(game.data, "TOTODILE", 12)
assert(lead and bench, "the cache carries no starters to seed a party")
bench.hp = 0
bench.status = "faint"
save.party = { lead, bench }
save.inventory = { REVIVE = 1, ETHER = 1 }
local function battleScreen()
local top = game.stack:top()
return (top and top.battle) and top or nil
end
local function tapUntil(predicate, tries, btn)
for _ = 1, tries or 400 do
if predicate() then return true end
U.tap(game, btn or "a")
U.wait(2)
end
return predicate()
end
local wild = Mon.new(game.data, "PIDGEY", 3)
assert(wild, "the cache carries no PIDGEY")
assert(world:startBattle({ wild = wild }), "the wild battle refused to start")
assert(tapUntil(function()
local screen = battleScreen()
return screen ~= nil and screen.phase == "menu"
end), "the battle never reached BattleMenu")
local screen = battleScreen()
-- BattleMenuHeader's 2x2 grid, filled row-major: 1 FIGHT / 2 PkMn on top,
-- 3 PACK / 4 RUN below. LEFT swaps an even column to its odd neighbour and
-- DOWN swaps the row, so those two presses reach PACK from any cursor.
local function openPack()
for _ = 1, 300 do
if screen.phase == "moves" then
U.tap(game, "b")
U.wait(2)
end
if screen.phase == "menu" then break end
U.wait(1)
end
assert(screen.phase == "menu", "the battle menu never came back")
if screen.menuIndex % 2 == 0 then
U.tap(game, "left")
U.wait(3)
end
if screen.menuIndex <= 2 then
U.tap(game, "down")
U.wait(3)
end
assert(screen.menuIndex == 3,
"the cursor sat on menu slot " .. tostring(screen.menuIndex))
U.tap(game, "a")
U.wait(4)
local pack = game.stack:top()
assert(pack and pack.rows, "the battle PACK did not open")
return pack
end
-- ---- REVIVE on the fainted BENCHED mon ----------------------------------
local pack = openPack()
local reviveRow
for index, row in ipairs(pack.rows) do
if row.id == "REVIVE" then reviveRow = index end
end
assert(reviveRow, "the battle PACK does not show the REVIVE")
for _ = 2, reviveRow do
U.tap(game, "down")
U.wait(2)
end
U.tap(game, "a")
U.wait(4)
local party = game.stack:top()
assert(party and party.prompt, "the REVIVE did not open UseItem_SelectMon")
-- Down to the second slot, which is the fainted one, then take it.
U.tap(game, "down")
U.wait(2)
U.tap(game, "a")
U.wait(6)
local half = math.max(1, math.floor((bench.maxHp or bench.stats.hp) / 2))
assert(bench.hp == half,
"the REVIVE left the benched mon at " .. tostring(bench.hp)
.. ", not ReviveHalfHP's " .. half)
assert(save.inventory.REVIVE == nil, "the REVIVE was not consumed")
U.log("PASS battle pack: REVIVE stands a BENCHED mon up mid-battle")
-- ---- ETHER through the move list ----------------------------------------
assert(tapUntil(function()
return screen.phase == "menu" or screen.phase == "moves"
end), "the revive turn never drained back to the menu")
local slot = lead.moves and lead.moves[1]
assert(slot, "the lead mon knows no moves")
slot.pp = math.max(0, (slot.maxPp or slot.pp or 10) - 12)
local before = slot.pp
local ppPack = openPack()
local etherRow
for index, row in ipairs(ppPack.rows) do
if row.id == "ETHER" then etherRow = index end
end
assert(etherRow, "the battle PACK does not show the ETHER")
for _ = 2, etherRow do
U.tap(game, "down")
U.wait(2)
end
U.tap(game, "a")
U.wait(4)
local pickMon = game.stack:top()
assert(pickMon and pickMon.prompt, "the ETHER did not open the party list")
U.tap(game, "a")
U.wait(4)
local moveList = game.stack:top()
assert(moveList and moveList ~= pickMon and moveList.list,
"the ETHER did not open the move list")
U.tap(game, "a")
U.wait(6)
assert(slot.pp == math.min(slot.maxPp or slot.pp, before + 10),
"the ETHER restored " .. tostring(slot.pp - before) .. " PP, not 10")
assert(save.inventory.ETHER == nil, "the ETHER was not consumed")
U.log("PASS battle pack: ETHER restores PP through the move list")
U.log("PASS gold_battle_items")
love.event.quit()
end
+92
View File
@@ -0,0 +1,92 @@
-- Probe: the PACK opened from a real Gold battle menu, on a real overworld.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_pack_probe.lua love .
--
-- BattlePack (engine/items/pack.asm) is a different jumptable from the field
-- PACK's, and its first four entries are .Oak: a key item picked mid-fight
-- prints OakThisIsntTheTimeText inside the pack. Nothing here may reach the
-- field jumptable, whose ITEMFINDER arm quits the PACK -- over a battle that
-- takes the battle off the stack with it.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local PackMenu = require("src.ui.gen2.PackMenu")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-pack"
local function tap(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
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local player = Mon.new(game.data, "CYNDAQUIL", 12)
game.save.party = { player }
game.save.inventory = { POKE_BALL = 5, POTION = 3, ITEMFINDER = 1,
NORMAL_BOX = 1 }
local wild = Mon.new(game.data, "PIDGEY", 4)
assert(world:startBattle({ wild = wild }), "startBattle failed")
-- DoBattleTransition owns the screen first; wBattleMode is only set when the
-- battle screen itself goes on the stack.
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
assert(battle, "battle screen never came up")
assert(world.battleActive, "the world is not marked as in a battle")
for _ = 1, 240 do
if battle.phase == "menu" then break end
tap("a", 3)
end
assert(battle.phase == "menu", "never reached the battle menu")
-- The 2x2 grid: DOWN puts the cursor on PACK.
tap("down")
tap("a")
local pack = game.stack:top()
assert(getmetatable(pack) == PackMenu, "PACK did not open the pack")
assert(pack:inBattle(), "the battle pack is not flagged as BattlePack")
-- KEY ITEMS, then A on the ITEMFINDER.
tap("right")
tap("right")
assert(pack:pocket().id == "KEY_ITEM",
"did not reach the KEY ITEMS pocket: " .. tostring(pack:pocket().id))
assert(pack.rows[1], "the key items pocket is empty")
print("[driver] key item row 1 " .. tostring(pack.rows[1].id))
tap("a")
U.wait(4)
U.shot(game, out .. "/00-battle-pack-oak.png")
assert(pack.message and pack.message[1] == "OAK: {PLAYER}!",
"the ITEMFINDER did not print OakThisIsntTheTimeText")
assert(game.stack:top() == pack, "the pack left the stack")
assert(world.battleActive, "battleActive was cleared by a field effect")
assert(world.queuedScript == nil, "a field script was queued from a battle")
assert(game.save.inventory.ITEMFINDER == 1, "the key item was spent")
-- B clears the message, B again closes the pack, and the battle is still
-- there underneath with its menu.
tap("b")
tap("b")
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.wait(1)
end
assert(game.stack:top() == battle, "the battle is not back on top")
assert(battle.phase == "menu", "the battle menu did not come back")
U.shot(game, out .. "/01-battle-menu-back.png")
print("[driver] PASS gold battle pack in " .. out)
end
+294
View File
@@ -0,0 +1,294 @@
-- The battle RULES that a fixture test cannot see: when TRANSFORM actually
-- lands on screen, what a caught mon's record says, and that STRUGGLE,
-- MAGNITUDE, DREAM EATER, SPITE and a refused RUN behave the way
-- engine/battle/effect_commands.asm and engine/battle/core.asm say they do --
-- all of it against the live extracted tables and the real battle screen
-- rather than a fixture.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_rules.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-rules love .
--
-- The shots are the point of the first block: BattleState draws the enemy pic
-- from whichever mon its `shownMon` slot holds, and that slot follows the
-- EVENT QUEUE. Battle:takeTurn resolves a whole round up front, so anything
-- the rules write straight into the mon record is on screen a beat before its
-- own message -- which is what "DITTO transformed at the beginning of its
-- turn" looked like. 01-submitted.png is taken one frame after the move is
-- submitted and before any message has been drained, and 02-transformed.png
-- once TRANSFORM's own line has been read: the two shots are what the pic
-- timing has to be judged on, and the driver PRINTS which one the swap landed
-- on rather than asserting it, because the rules half of that (the `transform`
-- event, and the pre-transform record kept beside it) is all this side of the
-- seam owns -- src/ui/gen2/BattleState.lua owns `shownMon`.
--
-- What the driver does assert is every rule: the transform is undone on the
-- way out of the battle (a caught DITTO is a DITTO), STRUGGLE's damage and its
-- quarter-damage recoil, MAGNITUDE's rolled power, DREAM EATER's checkhit gate
-- and SPITE's PP drain, and a RUN refused by a trainer battle costing nothing.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
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
-- Drain the screen's own queue: press A until it is asking for a move again.
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function newWild(game, species, level, moves)
local mon = Mon.new(game.data, species, level)
if moves then
mon.moves = {}
for i, id in ipairs(moves) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
end
return mon
end
local function giveMoves(mon, game, moves)
mon.moves = {}
for i, id in ipairs(moves) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
return mon
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-rules"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- ------------------------------------------------------------------ 1 + 3
-- A wild DITTO whose only move is TRANSFORM, so the AI cannot pick anything
-- else, and a player mon slow enough that the DITTO moves second.
local player = Mon.new(game.data, "SNORLAX", 30)
giveMoves(player, game, { "TACKLE" })
game.save.party = { player }
game.save.inventory = { MASTER_BALL = 5 }
local ditto = newWild(game, "DITTO", 20, { "TRANSFORM" })
assert(world:startBattle({ wild = ditto }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 200)
U.shot(game, out .. "/00-menu.png")
print("[driver] enemy shown as " .. tostring(screen:activeMon("enemy")
and screen:activeMon("enemy").species))
screen:submit({ kind = "move", move = "TACKLE" })
local sawTransformEvent = false
for _, event in ipairs(screen.queue) do
if event.kind == "transform" then sawTransformEvent = true end
end
U.wait(1)
U.shot(game, out .. "/01-submitted.png")
local shownAtSubmit = screen:activeMon("enemy")
print("[driver] pic timing: at submit the enemy draws as "
.. tostring(shownAtSubmit and shownAtSubmit.species)
.. " (01-submitted.png)")
check(sawTransformEvent,
"the round carries a `transform` event for the screen to swap its pic on")
-- The frame the bug report is about: the player's own move is still on
-- screen, its animation has finished and the enemy pic is being drawn again
-- -- and the mon it is drawn from has already been rewritten by a TRANSFORM
-- whose line has not been read yet.
U.wait(120)
U.shot(game, out .. "/01z-before-transform-line.png")
print("[driver] with the box still reading "
.. tostring(screen.message and screen.message:gsub("\n", " / "))
.. " the enemy pic is drawn from "
.. tostring(screen:activeMon("enemy") and screen:activeMon("enemy").species)
.. " (01z-before-transform-line.png)")
-- One shot per message on the way through the round, so the frame where the
-- pic stops being a DITTO can be pointed at rather than described.
for step = 1, 6 do
U.tap(game, "a")
U.wait(24)
U.shot(game, out .. ("/01%s-step.png"):format(string.char(96 + step)))
print("[driver] step " .. step .. " message: "
.. tostring(screen.message and screen.message:gsub("\n", " / ")))
end
drain(game, screen, 400)
U.shot(game, out .. "/02-transformed.png")
local shownAfter = screen:activeMon("enemy")
print("[driver] pic timing: after TRANSFORM's own line it draws as "
.. tostring(shownAfter and shownAfter.species) .. " (02-transformed.png)")
check(ditto.species == "SNORLAX",
"the rules half lands at once: the battler IS the copy for the rest of "
.. "the round")
check(screen.battle:volatile(ditto).preTransform
and screen.battle:volatile(ditto).preTransform.species == "DITTO",
"and the record it was is kept for the reload")
-- The catch: PokeBallEffect reloads the caught mon out of its base data
-- (item_effects.asm `.catch_without_fail` reads wTempEnemyMonSpecies), so a
-- transformed DITTO is caught as a DITTO. A MASTER BALL so the roll is not
-- part of what is being tested.
screen:useItem("MASTER_BALL")
for _ = 1, 400 do
if game.save.party[2] then break end
U.tap(game, "a")
U.wait(3)
end
U.shot(game, out .. "/03-caught.png")
local caught = game.save.party[2]
check(caught ~= nil, "the DITTO was caught into the party")
-- CleanUpBattleRAM (BattleState:finishBattle -> clearAllVolatiles) is where
-- the reload lands, so the record is judged once the battle is off the
-- stack -- which is also the last moment before the overworld and the next
-- save write see it.
-- B, not A: the capture ends on AskGiveNicknameText, and answering NO is
-- what walks the screen through to ExitBattle instead of parking it in the
-- naming screen (which sits ON TOP of the battle, so "the battle is not the
-- top of the stack" is not the same as "the battle is over").
local done = false
for _ = 1, 900 do
if screen.phase == "done" then done = true break end
U.tap(game, "b")
U.wait(3)
end
U.wait(30)
print(("[driver] battle finished=%s phase=%s"):format(tostring(done),
tostring(screen.phase)))
check(done, "the battle screen reached ExitBattle")
if caught then
print("[driver] caught record: species=" .. tostring(caught.species)
.. " move1=" .. tostring(caught.moves and caught.moves[1]
and caught.moves[1].id))
check(caught.species == "DITTO",
"the caught record is the real DITTO (was "
.. tostring(caught.species) .. ")")
check(caught.moves and caught.moves[1]
and caught.moves[1].id == "TRANSFORM",
"and it kept its own move list")
end
-- ---------------------------------------------------------------------- 2
-- STRUGGLE: real damage, then a quarter of it back (BattleCommand_Recoil).
local struggler = Mon.new(game.data, "RATTATA", 30)
giveMoves(struggler, game, { "TACKLE" })
struggler.moves[1].pp = 0
game.save.party = { struggler }
local target = newWild(game, "SNORLAX", 30, { "SPLASH" })
assert(world:startBattle({ wild = target }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 200)
local foeBefore, mineBefore = target.hp, struggler.hp
screen:submit({ kind = "move", move = "TACKLE" })
drain(game, screen, 400)
U.shot(game, out .. "/04-struggle.png")
local dealt = foeBefore - target.hp
local recoil = mineBefore - struggler.hp
print(("[driver] STRUGGLE dealt %d and recoiled %d"):format(dealt, recoil))
check(dealt > 5, "STRUGGLE deals its 50 power, not chip damage")
check(recoil == math.max(1, math.floor(dealt / 4)),
"and the user takes a quarter of it back")
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(60)
-- ------------------------------------------------------------------ 4/5/6
local caster = Mon.new(game.data, "GASTLY", 40)
giveMoves(caster, game, { "MAGNITUDE", "DREAM_EATER", "SPITE" })
caster.hp = math.max(1, caster.hp - 20)
game.save.party = { caster }
local dummy = newWild(game, "RATTATA", 20, { "TACKLE" })
assert(world:startBattle({ wild = dummy }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 200)
local before = dummy.hp
screen:submit({ kind = "move", move = "MAGNITUDE" })
local sawMagnitude = false
for _, event in ipairs(screen.queue) do
if event.text and event.text:match("^Magnitude %d") then
sawMagnitude = true
end
end
drain(game, screen, 400)
U.shot(game, out .. "/05-magnitude.png")
check(sawMagnitude, "MAGNITUDE announces its rolled magnitude")
check(before - dummy.hp > 1,
"and hits for the rolled power, not the ROM's stored 1 (dealt "
.. tostring(before - dummy.hp) .. ")")
local hpBefore, mineHp = dummy.hp, caster.hp
screen:submit({ kind = "move", move = "DREAM_EATER" })
drain(game, screen, 400)
check(dummy.hp == hpBefore,
"DREAM EATER misses an awake target outright")
check(caster.hp <= mineHp, "and saps nothing from it")
local ppBefore = dummy.moves[1].pp
screen:submit({ kind = "move", move = "SPITE" })
drain(game, screen, 400)
U.shot(game, out .. "/06-spite.png")
print(("[driver] SPITE took the foe's TACKLE from %d to %d PP")
:format(ppBefore, dummy.moves[1].pp))
check(dummy.moves[1].pp < ppBefore - 1,
"SPITE drains 2-5 PP off the move the target last used")
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(60)
-- ---------------------------------------------------------------------- 7
-- RUN in a trainer battle: `.cant_run_from_trainer` leaves
-- wBattlePlayerAction alone and falls back into BattleMenu, so the round is
-- never spent and the trainer does not get a free swing.
local runner = Mon.new(game.data, "RATTATA", 30)
giveMoves(runner, game, { "TACKLE" })
game.save.party = { runner }
local foe = Mon.new(game.data, "GEODUDE", 30)
giveMoves(foe, game, { "TACKLE" })
assert(world:startBattle({ trainer = { class = "YOUNGSTER",
name = "JOEY", party = { foe } } }), "trainer startBattle failed")
screen = battleScreen(game)
drain(game, screen, 300)
local hpAtRun = runner.hp
local turnAtRun = screen.battle.turn
screen:submit({ kind = "run" })
drain(game, screen, 300)
U.shot(game, out .. "/07-run-refused.png")
check(runner.hp == hpAtRun,
"a refused RUN costs no HP: the trainer never got a swing")
check(screen.battle.turn == turnAtRun + 1 and not screen.battle.over,
"and the battle is still running")
check(foe.hp == foe.maxHp, "nor did anything happen to the foe")
if #failures > 0 then
for _, what in ipairs(failures) do print("[FAIL] " .. what) end
error(#failures .. " battle-rule checks failed")
end
print("[driver] all battle-rule checks passed")
print("[driver] shots in " .. out)
end
+370
View File
@@ -0,0 +1,370 @@
-- 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
+109
View File
@@ -0,0 +1,109 @@
-- Smoke: a real Gold wild battle, driven end to end from the overworld.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_smoke.lua love .
--
-- Starts a battle against a live extracted species with a live extracted
-- moveset, presses FIGHT until something faints, and shoots the screen along
-- the way. This is the check that the extracted moves/pokemon/type_chart
-- actually agree with the engine -- a fixture test cannot say that.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle"
local function tap(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
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- `givepoke` is how the STARTER arrives, and it used to hand back a mon with
-- an empty move list (it went through Gen 1's Pokemon.new, whose
-- level1Moves/learnset fields the Gen 2 extractor does not write). That is
-- what left FIGHT with nothing in it. Drive the VM hook directly so the
-- check does not depend on walking the whole Elm's Lab script.
local cyndaquilIndex = game.data.pokemon.CYNDAQUIL.index
game.save.party = {}
world.vm.givePokeFn(cyndaquilIndex, 5, 0)
local gift = game.save.party[1]
assert(gift, "givepoke put nothing in the party")
assert(#gift.moves > 0,
"givepoke handed over a mon with no moves -- FIGHT would be empty")
print(("[driver] givepoke gave %s L%d with %d moves (%s)"):format(
gift.species, gift.level, #gift.moves, gift.moves[1].id))
assert(game.save.pokedex and game.save.pokedex.caught[gift.species],
"givepoke did not tick the starter off in the #DEX")
-- Give the player a real Cyndaquil built from the extracted tables, so the
-- moveset and stats come from the cart rather than the driver.
local player = Mon.new(game.data, "CYNDAQUIL", 12)
assert(player, "could not build a CYNDAQUIL from pokemon.lua")
assert(#player.moves > 0,
"CYNDAQUIL learned no moves -- levelMoves or moves.lua is missing")
print(("[driver] player %s L%d hp %d/%d, %d moves (%s)"):format(
player.species, player.level, player.hp, player.maxHp, #player.moves,
player.moves[1].id))
game.save.party = { player }
game.save.inventory = { POKE_BALL = 5, POTION = 3 }
local wild = Mon.new(game.data, "PIDGEY", 4)
assert(wild and #wild.moves > 0, "could not build a wild PIDGEY")
local Music = require("src.core.Music")
assert(world:startBattle({ wild = wild }), "startBattle failed")
-- PlayBattleMusic runs before the transition, so the theme is already going
-- while the wipe is spinning.
print("[driver] battle music " .. tostring(Music.current()))
assert(Music.current() == "Music_JohtoWildBattle"
or Music.current() == "Music_JohtoWildBattleNight",
"the wild battle did not start the Johto wild theme: "
.. tostring(Music.current()))
-- DoBattleTransition owns the screen first; shoot it, then wait it out.
U.wait(4)
U.shot(game, out .. "/00-transition.png")
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
assert(battle and battle.battle,
"battle screen never came up after the transition")
U.wait(20)
U.shot(game, out .. "/01-battle-open.png")
-- Page through the intro messages, then attack until the battle resolves.
for _ = 1, 120 do
if battle.battle.over then break end
if battle.phase == "menu" then
U.shot(game, out .. "/02-battle-menu.png")
tap("a") -- FIGHT
U.wait(4)
U.shot(game, out .. "/03-move-list.png")
tap("a") -- first move
else
tap("a", 3)
end
end
assert(battle.battle.over,
"battle did not resolve in 120 presses (phase " .. tostring(battle.phase) .. ")")
print("[driver] outcome " .. tostring(battle.battle.outcome))
assert(battle.battle.outcome == "win",
"expected the L12 starter to win, got " .. tostring(battle.battle.outcome))
print(("[driver] player ended at %d/%d hp, exp %d")
:format(player.hp, player.maxHp, player.experience))
assert(player.experience > 0, "no experience was awarded")
U.shot(game, out .. "/04-battle-end.png")
print("[driver] PASS gold wild battle in " .. out)
end
+168
View File
@@ -0,0 +1,168 @@
-- Smoke: the whole Gold boot chain, with no driver shortcut.
--
-- POKEPORT_GAME=gold POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_DRIVER=tests/drivers/gold_boot_smoke.lua love .
--
-- copyright -> GameFreak -> GS intro -> title -> intro menu -> NEW GAME ->
-- Oak speech -> name pick -> naming screen -> the bedroom. Every step is
-- asserted by the class of the state on the stack, so a broken hand-off names
-- the screen it stalled on instead of just hanging.
local U = require("tests.drivers.util")
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local StartMenu = require("src.ui.gen2.StartMenu")
local TitleState = require("src.ui.gen2.TitleState")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-boot"
local function top()
return game.stack:top()
end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
local function tap(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 4)
end
-- Wait until `predicate` holds, or fail naming what was on screen instead.
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
local state = top()
error(("stalled waiting for %s (top is %s)"):format(
label, tostring(state)))
end
-- Skip through anything that just waits for a button.
local function press(times, button)
for _ = 1, times or 1 do tap(button or "a", 3) end
end
-- The driver env normally boots straight to the world; this one asked for the
-- cinema, so Game2 should have started at the copyright splash.
U.wait(10)
assert(isA(CopyrightSplash),
"boot did not start at the copyright splash (top " .. tostring(top()) .. ")")
U.shot(game, out .. "/01-copyright.png")
waitFor("GameFreak presents", function() return isA(GameFreakPresents) end)
U.shot(game, out .. "/02-gamefreak.png")
waitFor("the GS intro", function() return isA(GoldSilverIntro) end)
U.wait(240)
U.shot(game, out .. "/03-intro.png")
tap("start") -- any button skips the movie
waitFor("the title screen", function() return isA(TitleState) end)
U.wait(60)
U.shot(game, out .. "/04-title.png")
press(3, "start")
waitFor("the intro menu", function() return isA(MainMenu) end)
U.shot(game, out .. "/05-mainmenu.png")
-- OPTION and back, so the menu's own hand-off is exercised too. Found by
-- value, not by position: the port's EXIT GAME row sits after OPTION, so
-- "the last item" is the one that quits the game.
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "option" then menu.list.index = i end
end
tap("a")
waitFor("the options screen", function() return isA(OptionsMenu) end)
U.shot(game, out .. "/06-options.png")
tap("b")
waitFor("the intro menu again", function() return isA(MainMenu) end)
-- NEW GAME.
menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
-- `farcall InitClock` opens OakSpeech, so the clock screen is what NEW GAME
-- lands on and Oak is underneath it.
waitFor("the clock screen", function() return isA(InitClock) end, 300)
U.shot(game, out .. "/06b-initclock.png")
for _ = 1, 60 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 300)
-- Page through Oak until the name picker appears.
for _ = 1, 400 do
if isA(NamePick) then break end
tap("a", 2)
end
assert(isA(NamePick), "Oak speech never reached the name picker")
-- NamePlayer walks the player pic across before the menu box goes up, and
-- that walk is a blocking DelayFrame loop on the cart -- so both the shot
-- and the first button press have to wait it out.
waitFor("the name menu to slide in",
function() return top().slide == nil end, 120)
U.shot(game, out .. "/07-namepick.png")
-- NEW NAME opens the real Gen 2 keyboard.
local pick = top()
pick.cursor = 1 -- "NEW NAME"
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
-- Type "AB": A is at (0,0), B at (1,0).
tap("a")
tap("right")
tap("a")
assert(naming.text == "AB",
"typed name is " .. tostring(naming.text) .. ", expected AB")
U.shot(game, out .. "/08-naming.png")
-- END: bottom row, third target.
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
-- Back into Oak for the last text page and the shrink, then the world. This
-- one has to keep pressing A: the remaining pages are text boxes waiting on a
-- button, so a passive wait would sit there forever.
for _ = 1, 500 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
assert(game.phase == "play" and game.world and game.world.map,
"never reached the overworld (top is " .. tostring(top()) .. ")")
assert(game.save.player.name == "AB",
"player name is " .. tostring(game.save.player.name))
assert(game.world.map.id == "PLAYERS_HOUSE_2F",
"new game started on " .. tostring(game.world.map.id)
.. ", expected the bedroom")
U.wait(20)
U.shot(game, out .. "/09-bedroom.png")
-- START opens the Gen 2 start menu from the overworld.
tap("start")
waitFor("the start menu", function() return isA(StartMenu) end)
U.shot(game, out .. "/10-startmenu.png")
tap("b")
waitFor("the overworld again", function() return top() == nil end)
print("[driver] PASS gold boot chain in " .. out)
end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
-- ReleaseTheBeasts (maps/BurnedTowerB1F.asm:25), the one scene in the game
-- that stages six objects sharing two event flags one beat at a time.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_burned_tower_beasts.lua love .
--
-- What a human is watching for, in the cart's own order: Raikou APPEARS, its
-- standing twin blinks out, Raikou cries; then Entei, then Suicune, each on
-- its own beat -- and at the end each of the three jumps away and vanishes on
-- its own turn rather than all three going at once.
--
-- All three animated beasts carry EVENT_BURNED_TOWER_B1F_BEASTS_1 and all
-- three statics carry EVENT_BURNED_TOWER_B1F_BEASTS_2, so a port that derives
-- who is standing from the event flag alone pops the whole group on the first
-- `appear` and clears it on the first `disappear`. wObjectMasks is the per
-- object byte that keeps them independent (home/map.asm:1542 MaskObject,
-- engine/overworld/map_objects_2.asm:1 LoadObjectMasks).
--
-- Shots land in /tmp/gold-beasts, one per beat plus a running census.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-beasts"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- The scene is entered from the ladder; drop in beside the trigger instead,
-- which is the coord event at (9,5).
world:setMap("BURNED_TOWER_B1F", 9, 7, "up")
U.wait(20)
-- The coord event carries its own scene id, so ask the map rather than
-- hardcoding SCENE_BURNEDTOWERB1F_RELEASE_THE_BEASTS.
local trigger
for _, ev in ipairs(world.map.def.coordEvents or {}) do
if ev.x == 9 and ev.y == 5 then trigger = ev end
end
assert(trigger, "BURNED_TOWER_B1F has no coord event at (9,5)")
world.mapScenes[world.map.id] = trigger.sceneId or 0
local function census()
local n = 0
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.eventFlag and npc.def.eventFlag ~= 0xFFFF then
n = n + 1
end
end
return n
end
U.shot(game, out .. "/00-before.png")
print(("[driver] %d flagged objects standing before the scene")
:format(census()))
-- Walk onto the trigger and let the scene run, shooting every beat.
U.hold(game, "up", 24)
U.wait(10)
local counts = {}
for step = 1, 60 do
U.wait(10)
counts[#counts + 1] = census()
if step % 3 == 0 then
U.shot(game, ("%s/01-beat-%02d.png"):format(out, step))
end
if step > 6 and not world:busy() then break end
end
U.wait(30)
U.shot(game, out .. "/02-after.png")
-- The census must never jump by three: every appear and every disappear in
-- ReleaseTheBeasts moves exactly one object.
local worst = 0
for i = 2, #counts do
local delta = math.abs(counts[i] - counts[i - 1])
if delta > worst then worst = delta end
end
print(("[driver] %d samples, largest one-sample swing %d (want 1)")
:format(#counts, worst))
print(("[driver] %d flagged objects standing after the scene")
:format(census()))
print("[driver] PASS gold burned tower beasts in " .. out)
love.event.quit()
end
+171
View File
@@ -0,0 +1,171 @@
-- Catch-op probe: resume a checkpoint and hunt one species.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=08 \
-- POKEPORT_GOLD_CATCH="ECRUTEAK_CITY:POLIWAG,POLIWHIRL" \
-- POKEPORT_GOLD_CATCH_WATER=1 \
-- POKEPORT_DRIVER=tests/drivers/gold_catch_probe.lua love .
--
-- Used to prove a water/grass hunt without replaying the section that leads
-- up to it. Placement is travelTo (or a teleport if POKEPORT_GOLD_CATCH_TP=1);
-- the measurement is ops.catch.
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
if not A.ready(game) then
print("[catch-probe] the world never came up")
return
end
local resume = os.getenv("POKEPORT_GOLD_RESUME")
if resume then
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[catch-probe] cannot resume %s: %s"):format(resume, tostring(err)))
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
end
local spec = os.getenv("POKEPORT_GOLD_CATCH")
if not spec then
print("[catch-probe] set POKEPORT_GOLD_CATCH=MAP:SPECIES[,SPECIES...]")
return
end
local map, rest = spec:match("^%s*([%w_]+)%s*:%s*(.+)%s*$")
if not map then
print(("[catch-probe] cannot parse %q"):format(spec))
return
end
local species = {}
for id in rest:gmatch("[%w_]+") do species[#species + 1] = id end
bot:forgetSurf()
print(("[catch-probe] start on %s at %d,%d; surf=%s; balls=%s")
:format(tostring(A.mapId(game)), select(1, A.pos(game)),
select(2, A.pos(game)),
tostring(bot:canSurf()),
tostring(game.save and game.save.inventory
and game.save.inventory.POKE_BALL)))
if os.getenv("POKEPORT_GOLD_CATCH_TP") == "1" then
if not A.teleport(game, map, 20, 20) then
print("[catch-probe] teleport failed")
return
end
bot:wait(30)
else
if A.mapId(game) ~= map then
local ok = bot:travelTo(map)
print(("[catch-probe] travelTo %s: %s"):format(map, ok and "ok" or "FAIL"))
if not ok then return end
end
end
-- Same hunt loop as ops.catch (water filter + bot.catchWanted throws).
print("[catch-probe] hunting " .. table.concat(species, "/"))
local wanted = {}
for _, id in ipairs(species) do wanted[id] = true end
local function have()
for _, mon in ipairs(A.party(game)) do
if wanted[mon.species] then return mon end
end
end
if have() then
print("[catch-probe] already have one: " .. have().species)
return
end
local water = os.getenv("POKEPORT_GOLD_CATCH_WATER") == "1"
bot:forgetSurf()
if water and not bot:canSurf() then
print("[catch-probe] FAIL: water catch needs SURF and FOGBADGE")
return
end
local ball = os.getenv("POKEPORT_GOLD_CATCH_BALL") or "POKE_BALL"
bot.catchWanted, bot.catchBall = wanted, ball
local start = bot:frames()
local caught = nil
for pass = 1, 300 do
caught = have()
if caught then break end
if not A.hasItem(game, ball) then
print("[catch-probe] FAIL: out of " .. ball)
break
end
if A.busy(game) then bot:clearDialogue({ "no", "no" }, 2000) end
if A.mapId(game) ~= map then
if not bot:travelTo(map) then
print("[catch-probe] FAIL: left map and could not return")
break
end
end
local m = A.map(game)
if not m then break end
local spots = {}
for cy = 0, m.heightCells - 1 do
for cx = 0, m.widthCells - 1 do
if A.isEncounterCell(m, cx, cy)
and (not water or A.isWater(m, cx, cy)) then
spots[#spots + 1] = { cx, cy }
end
end
end
if pass == 1 then
print(("[catch-probe] %d encounter spots (water=%s)")
:format(#spots, tostring(water)))
end
if #spots == 0 then
print("[catch-probe] FAIL: no encounter spots")
break
end
for _ = 1, 8 do
local pick = spots[math.random(1, #spots)]
if bot:planPath(pick[1], pick[2]) then
bot:walkTo(pick[1], pick[2], { attempts = 3 })
break
end
end
if pass % 20 == 0 then
local left = (game.save and game.save.inventory
and game.save.inventory[ball]) or 0
print(("[catch-probe] still hunting pass=%d balls=%d party=%d")
:format(pass, left, A.partySize(game)))
end
end
bot.catchWanted, bot.catchBall = nil, nil
if A.busy(game) then bot:clearDialogue({ "no", "no" }, 2000) end
caught = have()
if caught then
print(("[catch-probe] OK: caught %s in %d frames")
:format(caught.species, bot:frames() - start))
-- Prove the HM lists that motivated the catch.
local def = game.data and game.data.pokemon and game.data.pokemon[caught.species]
local haveHm = {}
for _, id in ipairs((def and def.tmhm) or {}) do
if id == "WHIRLPOOL" or id == "WATERFALL" or id == "SURF"
or id == "STRENGTH" or id == "FLY" then
haveHm[#haveHm + 1] = id
end
end
print(("[catch-probe] tmhm of interest: %s")
:format(#haveHm > 0 and table.concat(haveHm, ", ") or "(none)"))
else
print(("[catch-probe] FAIL: did not catch in %d frames")
:format(bot:frames() - start))
end
end
+137
View File
@@ -0,0 +1,137 @@
-- Assertion driver: the Pokecenter PC and the bedroom PC, in the running game.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_center_pc.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- What it proves that tests/gen2_pc_screens_test.lua cannot: the whole chain
-- inside a live love session -- the A press on the Cherrygrove Pokecenter's
-- COLL_PC tile runs PCScript (engine/events/std_scripts.asm) through the real
-- input path, `special PokemonCenterPC` opens the whose-PC menu, <PLAYER>'s
-- PC deposits an item into save.pcItems, and the bedroom PC opens the ITEM PC
-- (PLAYERSPC_HOUSE), not the storage system.
--
-- Shots land in /tmp/gold-center-pc.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-center-pc"
local fails = 0
local function ok(cond, msg)
if cond then print("[centerpc] ok " .. msg)
else fails = fails + 1 print("[centerpc] FAIL " .. msg) end
return cond
end
local function tap(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 4)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
local Mon = require("src.battle.gen2.Mon")
save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
save.inventory = save.inventory or {}
save.inventory.POTION = (save.inventory.POTION or 0) + 2
save.pcItems = {}
local potionsBefore = save.inventory.POTION
local function topId()
local top = game.stack:top()
return top and top.screenId or nil
end
-- ---- the Pokecenter ------------------------------------------------------
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"),
"setMap CHERRYGROVE_POKECENTER_1F failed")
U.wait(5)
local pcX, pcY
for cy = 0, w.map.heightCells - 1 do
for cx = 0, w.map.widthCells - 1 do
if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end
end
end
assert(pcX, "no COLL_PC tile in the Pokecenter")
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"),
"setMap onto the PC tile failed")
U.wait(5)
tap("a", 8)
ok(topId() == "Gen2CenterPcMenu",
"A at the Pokecenter PC opens the whose-PC menu (top: "
.. tostring(topId()) .. ")")
U.shot(game, out .. "/01-turned-on.png")
tap("a", 4) -- the turn-on line
U.shot(game, out .. "/02-whose-pc.png")
-- <PLAYER>'s PC, then DEPOSIT ITEM, then one POTION into the PC.
tap("down", 4)
tap("a", 4)
tap("a", 4)
tap("a", 6) -- both PokecenterPlayersPCText pages
ok(topId() == "Gen2ItemPcMenu",
"<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")")
U.shot(game, out .. "/03-item-pc.png")
tap("down", 4)
tap("a", 6) -- DEPOSIT ITEM -> the PACK chooser
U.shot(game, out .. "/04-deposit-pack.png")
tap("a", 4) -- the POTION row
tap("a", 6) -- x1
ok(save.pcItems.POTION == 1,
"one POTION landed in save.pcItems (" .. tostring(save.pcItems.POTION) .. ")")
ok(save.inventory.POTION == potionsBefore - 1,
"and left the bag (" .. tostring(save.inventory.POTION) .. ")")
U.shot(game, out .. "/05-deposited.png")
tap("a", 4) -- the Deposited line
tap("b", 6) -- close the PACK
tap("b", 4) -- LOG OFF row is last; B logs off too
ok(topId() == "Gen2CenterPcMenu", "logging off returns to the whose-PC menu")
tap("b", 6) -- shutdown
ok(topId() == nil or topId() ~= "Gen2CenterPcMenu",
"B shuts the Pokecenter PC down")
-- ---- the bedroom ---------------------------------------------------------
local house = w.maps and w.maps.PLAYERS_HOUSE_2F
local hx, hy
for _, ev in ipairs((house and house.bgEvents) or {}) do
if ev.kind == 1 then hx, hy = ev.x, ev.y end -- BGEVENT_UP: the PC
end
assert(hx, "no BGEVENT_UP bg event in PLAYERS_HOUSE_2F")
assert(w:setMap("PLAYERS_HOUSE_2F", hx, hy + 1, "up"),
"setMap PLAYERS_HOUSE_2F failed")
U.wait(5)
tap("a", 8)
local top = game.stack:top()
ok(top and top.screenId == "Gen2ItemPcMenu",
"the bedroom PC is the ITEM PC (top: " .. tostring(topId()) .. ")")
ok(top and top.house == true, "in its PLAYERSPC_HOUSE shape")
U.shot(game, out .. "/06-bedroom-boot.png")
tap("a", 4) -- the turn-on line
U.shot(game, out .. "/07-bedroom-menu.png")
-- WITHDRAW the POTION deposited downstairs: the PC is one PC.
tap("a", 6)
tap("a", 4)
tap("a", 6)
ok(save.pcItems.POTION == nil,
"the POTION withdrawn upstairs left the PC")
ok(save.inventory.POTION == potionsBefore,
"and is back in the bag (" .. tostring(save.inventory.POTION) .. ")")
tap("a", 4) -- the Withdrew line
tap("b", 4) -- back to the menu
tap("b", 6) -- TURN OFF
ok(game.stack:top() ~= top, "closing the bedroom PC pops it")
ok(not w.vm:running(), "and PlayersHousePCScript ran to its end")
print(("[centerpc] %d failures"):format(fails))
love.event.quit(fails == 0 and 0 or 1)
end
+67
View File
@@ -0,0 +1,67 @@
-- Assertion driver: a run that skips the boot cinema still starts on an
-- anchored game clock.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_clock_anchor.lua love .
--
-- NewGame (pokegold engine/menus/intro_menu.asm) calls OakSpeech, whose first
-- line is `farcall InitClock`, so wStartHour / wStartMinute always exist before
-- InitializeWorld. A driver run never reaches that screen; without the anchor
-- the save has no base and every hour read falls through to the host clock, so
-- the same run is MORN in the morning and NITE at night and the grass rolls a
-- different table each time. Prints PASS/FAIL and quits, because LOVE only
-- flushes stdout on exit.
local U = require("tests.drivers.util")
local Clock = require("src.core.gen2.Clock")
local Palettes = require("src.world.gen2.Palettes")
return function(game)
local failures = 0
local function want(label, ok, detail)
if ok then
U.log("ok " .. label)
else
failures = failures + 1
U.log("FAIL " .. label .. " (" .. tostring(detail) .. ")")
end
end
U.wait(30)
local world = game.world
want("the world booted", world ~= nil and world.map ~= nil,
world and world.status)
if not (world and world.map) then
U.log(failures == 0 and "PASS" or "FAIL")
love.event.quit()
return
end
want("the new game anchored the clock", Clock.isSet(game.save),
"save.rtc.startMinute is nil")
local hour = world:hour()
want("World:hour reads that base", hour == Clock.hour(game.save),
("world %s vs clock %s"):format(tostring(hour),
tostring(Clock.hour(game.save))))
want("the map is lit by the same hour",
world.daytime == Palettes.daytimeFor(world.map.def, hour, world.flashUsed),
("daytime %s at hour %s"):format(tostring(world.daytime), tostring(hour)))
-- The pin a screenshot run uses has to move both halves together.
local forced = tonumber(os.getenv("POKEPORT_GOLD_HOUR") or "")
if forced then
want("POKEPORT_GOLD_HOUR pins World:hour", hour == forced % 24, hour)
want("and the palette follows it",
world.daytime == Palettes.daytimeFor(world.map.def, forced,
world.flashUsed), tostring(world.daytime))
end
-- The map's own PALETTE_* can pin the daytime (the bedroom is PALETTE_DAY),
-- so log the clock's answer next to the map's.
U.log(("clock %02d:%02d, clock daytime %s, map daytime %s, anchored %s"):format(
world:hour(), world:minute(), Palettes.clockDaytime(hour),
tostring(world.daytime), tostring(Clock.isSet(game.save))))
U.log(failures == 0 and "PASS" or "FAIL")
love.event.quit()
end
+146
View File
@@ -0,0 +1,146 @@
-- Assertion driver: the dig / escape triple that an ordinary door banks, and
-- the rod's BATTLETYPE_FISH, end to end in the running game. It PASSES or it
-- errors; there is nothing to eyeball.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_dig_warp.lua love .
--
-- tests/gen2_dig_warp_test.lua proves the rule over the real map headers with
-- a recording setMap; what it cannot prove is a genuine map load underneath
-- it. home/map.asm EnterMapWarp `.SaveDigWarp` banks the door on every
-- outdoor-to-indoor warp, so DARK CAVE entered off Route 31 must rope out onto
-- Route 31 and the same cave entered off Route 46 must rope out onto Route 46.
--
-- The tail rides World:updateFishing through the real stack: FishFunction's
-- `.goodtofish` writes BATTLETYPE_FISH beside the hooked mon, and that is the
-- one condition LureBallMultiplier reads.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
save.party = { Mon.new(game.data, "CYNDAQUIL", 12) }
assert(save.party[1], "the cache carries no CYNDAQUIL to seed a party")
local function tapUntil(predicate, tries, btn)
for _ = 1, tries or 300 do
if predicate() then return true end
U.tap(game, btn or "a")
U.wait(2)
end
return predicate()
end
local function settle(mapId)
for _ = 1, 300 do
if world.map.id == mapId and not world.mapSetup then return true end
U.wait(1)
end
return world.map.id == mapId and not world.mapSetup
end
-- Walk a real warp tile: stand on the door and take it, the way
-- TryTileCollisionEvent's warpcheck does.
local function useDoor(fromMap, warpIndex, intoMap)
assert(world:setMap(fromMap, 5, 5, "down"), "setMap " .. fromMap .. " failed")
U.wait(5)
local door = world.maps[fromMap].warps[warpIndex]
assert(door, fromMap .. " has no warp " .. warpIndex)
world.player.cellX, world.player.cellY = door.x, door.y
assert(world:takeWarp(door), fromMap .. " warp " .. warpIndex .. " refused")
assert(settle(intoMap), "did not arrive on " .. intoMap
.. " (on " .. tostring(world.map.id) .. ")")
return door
end
-- START, then walk the cursor to the PACK row and open it.
local function openPack()
U.tap(game, "start")
U.wait(3)
local menu = game.stack:top()
assert(menu and menu.list, "START menu did not open")
local guard = 0
while menu.list:current().value ~= "pack" do
U.tap(game, "down")
U.wait(2)
guard = guard + 1
assert(guard < 12, "no PACK row in the START menu")
end
U.tap(game, "a")
U.wait(3)
local pack = game.stack:top()
assert(pack and pack.rows, "PACK did not open")
return pack
end
-- ---- the door banks, and the rope comes out of it ------------------------
local function ropeOutOf(routeId, warpIndex)
local door = useDoor(routeId, warpIndex, "DARK_CAVE_VIOLET_ENTRANCE")
assert(world.backupWarp, routeId .. ": the cave door banked no triple")
assert(world.backupWarp.map == routeId,
routeId .. ": banked " .. tostring(world.backupWarp.map) .. " instead")
assert(world.backupWarp.warp == warpIndex,
routeId .. ": banked warp " .. tostring(world.backupWarp.warp))
save.inventory = { ESCAPE_ROPE = 1 }
local pack = openPack()
assert(pack.rows[1] and pack.rows[1].id == "ESCAPE_ROPE",
"the PACK does not show the ESCAPE ROPE")
-- A opens the item submenu (.ItemBallsKey_LoadSubmenu,
-- engine/items/pack.asm:243) and USE is its first row.
U.tap(game, "a")
U.wait(2)
U.tap(game, "a")
U.wait(3)
assert(game.stack:top() ~= pack,
"using the rope must quit the PACK (PACKSTATE_QUITRUNSCRIPT)")
tapUntil(function()
return game.stack:top() == nil and not world.mapSetup
and world.map.id ~= "DARK_CAVE_VIOLET_ENTRANCE"
end)
assert(world.map.id == routeId,
"the rope paid out to " .. tostring(world.map.id) .. ", not " .. routeId)
assert(world.player.cellX == door.x and world.player.cellY == door.y,
routeId .. ": the rope landed off the door tile")
assert(save.inventory.ESCAPE_ROPE == nil, "the rope was not consumed")
end
ropeOutOf("ROUTE_31", 3)
U.log("PASS dig warp: DARK CAVE off Route 31 ropes back onto Route 31")
ropeOutOf("ROUTE_46", 3)
U.log("PASS dig warp: the same cave off Route 46 ropes back onto Route 46")
-- Leaving a cave for a route is indoor-to-outdoor: nothing banks.
useDoor("ROUTE_31", 3, "DARK_CAVE_VIOLET_ENTRANCE")
local banked = world.backupWarp
useDoor("DARK_CAVE_VIOLET_ENTRANCE", 1, "ROUTE_31")
assert(world.backupWarp == banked,
"walking OUT of the cave rewrote the dig triple")
U.log("PASS dig warp: an indoor-to-outdoor door leaves the triple alone")
-- ---- the rod's own battle carries BATTLETYPE_FISH ------------------------
local hooked = Mon.new(game.data, "MAGIKARP", 10)
assert(hooked, "the cache carries no MAGIKARP")
world.fishing = { phase = "bite", timer = 0, outcome = "battle",
wild = hooked }
world:updateFishing()
tapUntil(function()
local top = game.stack:top()
return top ~= nil and top.battle ~= nil
end)
local screen = game.stack:top()
assert(screen and screen.battle, "the rod's bite did not push a battle")
assert(screen.battle.battleType == "fish",
"the rod's battle carries battleType "
.. tostring(screen.battle.battleType))
U.log("PASS fishing: the rod's encounter carries BATTLETYPE_FISH")
U.log("PASS gold_dig_warp")
love.event.quit()
end
+131
View File
@@ -0,0 +1,131 @@
-- The per-step event chain, in the running game.
--
-- `World` kept no step counter at all until this, so `Happiness.step` and
-- `Breeding.step` were written, tested and never called: eggs never hatched.
-- CountStep (engine/overworld/events.asm) now runs between the coord events and
-- the wild roll, and DoEggStep ticks at wStepCount $80.
--
-- This walks a real party with a real egg in it until the counter reaches the
-- egg phase, then asserts the slot came out of it as a Pokemon.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_egg_hatch.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-steps"
return function(game)
local w = game.world
local fails = 0
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function ok(cond, msg)
if cond then print("[steps] ok " .. msg)
else fails = fails + 1 print("[steps] FAIL " .. msg) end
return cond
end
local function clearDirs()
game.input.pressQueue = {}
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
game.input.sources[d] = nil
end
end
-- Walk back and forth on a clear row. Holding one direction for a fixed
-- stretch is the reliable shape here: a step is 16 pixels at one a frame, so
-- 20 held frames is always at least one full footfall.
local function pace(frames, dir)
for _ = 1, frames do
if w:busy() then clearDirs() return true end
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
clearDirs()
coroutine.yield()
return w:busy()
end
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
wait(45)
local save = game.save
-- No wild encounters and no coord events in the way: this is about the step
-- counter, not about what else a footfall can trigger.
w.mapScenes.NEW_BARK_TOWN = 1
w:setMap("NEW_BARK_TOWN", 6, 8, "down")
wait(15)
-- An egg on its last cycle, and a mon in front of it so the party is honest.
local Mon = require("src.battle.gen2.Mon")
local lead = Mon.new(game.data, "CYNDAQUIL", 5)
save.party = { lead, {
isEgg = true, species = "TOGEPI", name = "EGG", level = 5,
eggSteps = 1, dvs = lead.dvs, moves = {},
ot = save.player and save.player.name,
otId = save.player and save.player.id,
} }
save.stepCount = nil
save.poisonStepCount = nil
local Breeding = require("src.core.gen2.Breeding")
ok(Breeding.isEgg(save.party[2]), "the party starts with an egg in slot 2")
-- DoEggStep fires at wStepCount $80, so at most 128 footfalls from zero.
local hit = false
for i = 1, 300 do
if pace(24, (i % 2 == 1) and "left" or "right") then hit = true break end
if not Breeding.isEgg(save.party[2]) then hit = true break end
if i == 1 then
ok((save.stepCount or 0) > 0,
"one lap already moved wStepCount to " .. tostring(save.stepCount))
end
end
ok((save.stepCount or 0) > 0,
"the world counts steps at all now (wStepCount = "
.. tostring(save.stepCount) .. ")")
ok(hit, "and something fired inside 128 footfalls")
ok(save.stepCount == 0x80,
"at wStepCount $80, DoEggStep's own phase (got "
.. tostring(save.stepCount) .. ")")
game.capturePath = SHOT_DIR .. "/hatch-huh.png"
wait(4)
-- "Huh?" and the hatch line advance on A; the nickname prompt is a yes/no and
-- B is NO, which is the arm that keeps the species name (HatchEggs' own
-- .nonickname). Answering YES would push the naming screen, which is a stack
-- state rather than a World busy flag and would sit there forever.
for _ = 1, 600 do
if not w:busy() then break end
table.insert(game.input.pressQueue, w.choicebox and "b" or "a")
wait(3)
end
wait(20)
local slot = save.party[2]
ok(slot ~= nil and not Breeding.isEgg(slot), "the egg is no longer an egg")
ok(slot and slot.species == "TOGEPI", "it is a TOGEPI (got "
.. tostring(slot and slot.species) .. ")")
ok(slot and (slot.hp or 0) > 0 and slot.hp == slot.maxHp,
"at full health, the way HatchEggs copies MON_MAXHP into MON_HP")
ok(slot and slot.happiness == 0x78,
"with the hatch happiness of $78 (got "
.. tostring(slot and slot.happiness) .. ")")
ok(save.pokedex and save.pokedex.caught
and save.pokedex.caught.TOGEPI, "and SetSeenAndCaughtMon ticked the #DEX")
-- HatchEggs sets EVENT_TOGEPI_HATCHED (84) by hand, for this species alone.
ok(w.events and w.events:get(84) == true,
"and the world set EVENT_TOGEPI_HATCHED")
game.capturePath = SHOT_DIR .. "/hatched.png"
wait(30)
if fails > 0 then
error(("gold egg hatch: %d assertion(s) failed"):format(fails))
end
print("[driver] PASS gold per-step chain: the egg hatched")
end
+148
View File
@@ -0,0 +1,148 @@
-- Contact sheet: the egg hatch cutscene and the egg summary page.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_egg_hatch_shots.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- tests/drivers/gold_egg_hatch.lua walks a real party into a hatch and checks
-- the party record afterwards; this one puts a human in front of the parts of
-- it no assertion can reach. Four things to look at, in the order they are
-- shot into /tmp/gold-egg (POKEPORT_SHOT_DIR):
--
-- crack, wobble-right, wobble-left
-- The crack sits ON the shell and stays there. hSCX and
-- wGlobalAnimXOffset move the background and the objects the same way
-- (engine/pokemon/breeding.asm:707-719), so across the three shots the
-- egg and the crack shift together, never apart: lay them over each
-- other and the picture is the same one, two pixels either side of
-- where it rests. The crack's own position carries
-- .OAMData_1x1_Palette0's -4 on each axis (data/sprite_anims/oam.asm
-- :112-114), which puts the first one at screen (76, 52).
-- burst, fragments-*, fragments-gone
-- The ten shards fly for sixteen frames and then leave
-- (AnimSeq_RevealNewMon's `.finish_EggShell`). `fragments-gone` is
-- shot well after that and must show the hatchling alone.
-- hatchling
-- The pic is where PadFrontpic put it (engine/gfx/load_pics.asm:342).
-- The default species is SENTRET because its frontpic is 48px, the one
-- width the old centring rule placed four pixels wrong; POKEPORT_EGG
-- _SPECIES picks another.
-- summary-egg
-- EggStatsScreen's page (engine/pokemon/stats_screen.asm:747-794): the
-- EGG pic in the 7x7 block at hlcoord 0, 0, in the EGG palette row's
-- cream and brown rather than flat greys. The egg is one cycle from
-- hatching, so SFX_2_BOOPS sounds as the page opens.
local U = require("tests.drivers.util")
local EggHatchAnim = require("src.ui.gen2.EggHatchAnim")
local Screens = require("src.ui.Screens")
-- 80 hold + 8 rounds of wobbles and stills + 129 fragment frames = 482, with
-- room to spare before calling it hung.
local FRAME_LIMIT = 700
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-egg"
local species = os.getenv("POKEPORT_EGG_SPECIES") or "SENTRET"
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local Mon = require("src.battle.gen2.Mon")
local hatchling = Mon.new(game.data, species, 5)
assert(hatchling, "no such species: " .. species)
------------------------------------------------------------------ cutscene
local finished = false
local screen = EggHatchAnim.new(game, {
mon = hatchling, species = species,
onDone = function() finished = true end,
})
game.stack:push(screen)
-- Waiting on the screen's own state rather than a frame count: U.shot spins
-- until the capture reaches disk, so it eats frames of its own and a
-- frame-numbered target would drift past the beat it was aimed at. A wobble
-- half is only two frames long, shorter than that spin, so the screen is
-- held still for the capture as well -- otherwise wobble-right and
-- wobble-left would both be whatever beat the writer happened to land on.
local frozen = false
local advance = screen.update
screen.update = function(s, dt)
if frozen then return end
return advance(s, dt)
end
local frames = 0
local function until_(pred, what)
while not pred() and frames < FRAME_LIMIT do
U.wait(1)
frames = frames + 1
end
assert(frames < FRAME_LIMIT, "never reached: " .. what)
end
local function shot(name)
frozen = true
U.shot(game, out .. "/" .. name .. ".png")
frozen = false
end
-- The crack goes on at the end of a round's still frames and the next
-- round's first wobble half is entered in the same update, so wait for the
-- stillness after it: three shots, at shake 0, -2 and +2.
until_(function() return #screen.sprites > 0 and screen.shakeX == 0 end,
"the first crack, at rest")
shot("crack")
print(("[driver] first crack at (%d, %d) in struct coords")
:format(screen.sprites[1].x, screen.sprites[1].y))
until_(function() return screen.shakeX == 2 end, "a wobble's right half")
shot("wobble-right")
until_(function() return screen.shakeX == -2 end, "a wobble's left half")
shot("wobble-left")
until_(function() return screen.showMon end, "the shell breaking")
shot("burst")
for _, step in ipairs({ 4, 8, 12 }) do
U.wait(step)
frames = frames + step
shot(("fragments-%02d"):format(step))
end
until_(function() return #screen.sprites == 0 end, "the shards leaving")
print(("[driver] the fragments were gone %d frames in"):format(frames))
U.wait(40)
shot("fragments-gone")
shot("hatchling")
while not finished and frames < FRAME_LIMIT do
U.wait(1)
frames = frames + 1
end
assert(finished, "the cutscene never finished")
if game.stack:top() == screen then game.stack:pop() end
U.wait(10)
------------------------------------------------------------- summary page
-- One cycle left, which is EggStatsScreen's `cp 6` arm: the "It's making
-- sounds inside" line and SFX_2_BOOPS.
local egg = {
isEgg = true, species = species, name = "EGG", level = 5,
eggSteps = 1, dvs = hatchling.dvs, moves = {},
ot = game.save.player and game.save.player.name,
otId = game.save.player and game.save.player.id,
}
game.save.party = { hatchling, egg }
local summary = Screens.push(game, "Gen2SummaryMenu",
{ party = game.save.party, index = 2 })
U.wait(20)
U.shot(game, out .. "/summary-egg.png")
U.wait(20)
if game.stack:top() == summary then game.stack:pop() end
print("[driver] PASS gold egg hatch shots -> " .. out)
end
+99
View File
@@ -0,0 +1,99 @@
-- Contact sheet: the Gen 2 evolution animation, frame by frame.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_evolution_shots.lua love .
--
-- Shoots src/ui/gen2/EvolutionAnim.lua into /tmp/gold-evo, one frame every few,
-- for a normal evolution and then for a B-cancelled one. A test can assert
-- that the flash loop ran eight rounds; only a picture says the two pics are
-- swapping in the same 7x7 box at hlcoord 7, 2, that the silhouette really is
-- PREDEFPAL_BLACKOUT, and that the new mon's colours land on the last swap.
--
-- POKEPORT_EVO_SPECIES / POKEPORT_EVO_LEVEL pick the mon (default: a level 16
-- CHIKORITA, the first evolution a Gold playthrough actually reaches).
-- POKEPORT_SHOT_INTERVAL is how many frames apart the shots are.
local U = require("tests.drivers.util")
local Evolution = require("src.core.gen2.Evolution")
local EvolutionAnim = require("src.ui.gen2.EvolutionAnim")
local Mon = require("src.battle.gen2.Mon")
-- Frames to give one evolution before calling it hung: 50 (EvolvingText) + 80
-- (MUSIC_EVOLUTION) + 144 (the flash loop) + 64 (balls of light) + the text
-- pages, with room to spare.
local FRAME_LIMIT = 900
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-evo"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "4")
local species = os.getenv("POKEPORT_EVO_SPECIES") or "CHIKORITA"
local level = tonumber(os.getenv("POKEPORT_EVO_LEVEL") or "16")
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local mon = Mon.new(game.data, species, level)
assert(mon, "could not build a level " .. level .. " " .. species)
-- The after-battle sweep's context: no link, no stone, and the clock's own
-- time of day for the TR_MORNDAY / TR_NITE rows.
local Palettes = require("src.world.gen2.Palettes")
local entry = Evolution.checkMon(game.data, mon,
{ timeOfDay = Palettes.clockDaytime() })
assert(entry, species .. " at level " .. level
.. " has no evolution to show -- pick another with POKEPORT_EVO_SPECIES")
print(("[driver] %s -> %s"):format(species, entry.into))
-- One run of the screen, shooting every `interval` frames. `cancelAt` is
-- the frame to tap B on, which .WaitFrames_CheckPressedB only honours during
-- a hold; nil runs it through to the end.
local function run(prefix, cancelAt)
game.save.party = { Mon.new(game.data, species, level) }
local finished = nil
local screen = EvolutionAnim.new(game, {
mon = game.save.party[1],
entry = entry,
index = 1,
party = game.save.party,
save = game.save,
onDone = function(result) finished = result end,
})
game.stack:push(screen)
local frame = 0
while not finished and frame < FRAME_LIMIT do
if frame % interval == 0 then
U.shot(game, ("%s/%s-%04d-%s.png"):format(out, prefix, frame,
screen.phase or "?"))
end
if cancelAt and frame == cancelAt then
U.tap(game, "b")
else
U.wait(1)
end
frame = frame + 1
end
assert(finished, prefix .. " never finished")
game.stack:pop()
print(("[driver] %-8s %d frames, canceled=%s, species now %s"):format(
prefix, frame, tostring(finished.canceled),
tostring(game.save.party[1].species)))
return finished
end
local full = run("evolve", nil)
assert(not full.canceled, "the uncancelled run reported a cancel")
assert(game.save.party[1].species == entry.into,
"the party slot did not take the new species")
-- B during the very first hold, which is the 16 frames after the 50 + 80 of
-- text and music: .cancel_evo leaves the OLD pic on screen and prints
-- StoppedEvolvingText.
local canceled = run("cancel",
Evolution.EVOLVING_FRAMES + Evolution.MUSIC_FRAMES + 4)
assert(canceled.canceled, "the B press did not cancel the evolution")
assert(game.save.party[1].species == species,
"a cancelled evolution changed the species anyway")
print("[driver] PASS gold evolution in " .. out)
love.event.quit()
end
+115
View File
@@ -0,0 +1,115 @@
-- The exp bar crawl, the level number that rides it, and the siren that has to
-- stop when the enemy goes down. All three are things only a person watching
-- the screen can sign off on.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_exp_bar.lua love .
--
-- What to look for, in order:
-- 02..05 the blue bar under the player's HUD WALKS to the right, one pixel
-- at a time (AnimateExpBar, engine/battle/core.asm:7191), with
-- SFX_EXP_BAR sounding under it. It must not be at its final width
-- in shot 02 already.
-- 03 the ":L" number is still the PRE-kill level while the bar is
-- mid-crawl, and only changes on the frame the bar tops out
-- (wBattleMonLevel is written inside the level loop, :7267-7274),
-- with the end-of-bar hit playing there.
-- 06 "<mon> grew to level N!", which comes AFTER all of that.
-- And by ear: the low-HP siren is loud on the way in (the player is left on 3
-- HP on purpose), and is cut dead the moment the wild mon faints -- it must
-- not blare on under the victory jingle and the exp lines
-- (wBattleLowHealthAlarm, core.asm:2071-2074).
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-exp-bar"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local player = Mon.new(game.data, "CYNDAQUIL", 9)
assert(player and #player.moves > 0, "could not build a CYNDAQUIL")
-- One point short of the next level, so the single kill below crosses it and
-- the bar has to fill, restart at zero and finish the second segment.
local def = game.data.pokemon[player.species]
local growth = game.data.pokemon.growthRates[def.growthRate]
player.experience = Mon.experienceForLevel(growth, player.level + 1) - 1
-- Red bar on the way in, so the siren is up before the faint.
player.hp = 3
game.save.party = { player }
game.save.inventory = { POTION = 2 }
local wild = Mon.new(game.data, "PIDGEY", 6)
assert(wild, "could not build a wild PIDGEY")
assert(world:startBattle({ wild = wild }), "startBattle failed")
local screen
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
assert(screen and screen.battle, "battle screen never came up")
-- Page the intro out to the menu, with the siren already sounding.
for _ = 1, 200 do
if screen.phase == "menu" then break end
U.tap(game, "a")
U.wait(3)
end
assert(screen.phase == "menu", "never reached the battle menu")
U.shot(game, out .. "/00-red-bar-siren.png")
print("[driver] player " .. player.hp .. "/" .. player.maxHp
.. " hp, level " .. player.level .. ", exp " .. player.experience)
-- One hit ends it.
screen.battle.enemy.hp = 1
U.tap(game, "a") -- FIGHT
U.wait(6)
U.tap(game, "a") -- first move
U.shot(game, out .. "/01-the-kill.png")
-- Page forward until the crawl arms, shooting the level line as it stands.
local shots, armed = 0, false
for _ = 1, 900 do
if screen.expAnim then armed = true break end
if screen.phase == "done" then break end
U.tap(game, "a")
U.wait(2)
end
assert(armed, "the exp bar crawl never armed (phase "
.. tostring(screen.phase) .. ")")
print("[driver] crawl armed at level " .. tostring(screen.shownLevel)
.. ", bar at " .. tostring(screen.shownExp) .. "/64")
-- Four stills across the crawl. A bar that is already full in the first is
-- the bug this driver exists for.
local seen = {}
while screen.expAnim and shots < 4 do
shots = shots + 1
seen[shots] = { screen.shownExp, screen.shownLevel }
U.shot(game, out .. ("/%02d-crawl.png"):format(shots + 1))
U.wait(18)
end
for i = 1, shots do
print(("[driver] shot %d: bar %s/64, :L%s")
:format(i + 1, tostring(seen[i][1]), tostring(seen[i][2])))
end
assert(shots >= 2, "the crawl was over before two frames could be shot")
assert(seen[1][1] < 64, "the bar was already full on the first crawl frame")
-- The rest of the queue: the grew-to-level line and the way out.
for _ = 1, 900 do
if screen.phase == "done" then break end
if (screen.message or ""):find("grew to level") then
U.shot(game, out .. "/06-grew-to-level.png")
end
U.tap(game, "a")
U.wait(2)
end
print("[driver] ended at level " .. tostring(player.level)
.. ", HUD showing :L" .. tostring(screen.shownLevel))
print("[driver] PASS gold exp bar in " .. out)
end
+321
View File
@@ -0,0 +1,321 @@
-- The four things the extractor pass unblocked, driven in the real game.
--
-- Every one of them was a pointer the importer emitted raw, so the engine had
-- an address and nothing behind it. This driver walks the player to each and
-- asserts the feature actually runs, rather than shooting a screenshot for
-- someone to squint at:
--
-- 1. a scripted static menu -- Goldenrod Dept Store 6F's vending machine
-- (`loadmenu` / `verticalmenu`). Every one of the seventeen sites took
-- the cancel arm before the MenuHeader pointer was followed.
-- 2. the elevator -- Goldenrod Dept Store's, whose floor list
-- lives in its own script bank. The ride is a `warp_event` with
-- destination warp -1 reading what Elevator_GoToFloor left behind.
-- 3. a special phone call -- SPECIALCALL_ROBBED, whose script is in ROM
-- bank $41. Nothing on any map points into that bank; the seed is
-- PhoneContacts itself.
-- 4. an in-game trade -- NPC_TRADE_MIKE, off data/events/npc_trades.asm.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_extractor_pass.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-extractor"
return function(game)
local w = game.world
local fails = 0
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function ok(cond, msg)
if cond then print("[extract] ok " .. msg)
else fails = fails + 1 print("[extract] FAIL " .. msg) end
return cond
end
-- A tap is a press AND a release. Writing pressQueue directly injects a
-- press with no source map behind it (src/core/Input.lua Input:step), and
-- nothing else will ever clear it, so a tap that skipped the release would
-- leave that button HELD for the rest of the run -- which put DOWN under the
-- player's thumb on arrival in the elevator and walked them straight back out
-- through its COLL_WARP_CARPET_DOWN door.
local function tap(btn)
table.insert(game.input.pressQueue, btn)
coroutine.yield()
coroutine.yield()
game.input.state[btn] = false
end
-- DoPlayerMovement's .CheckWarp (engine/overworld/player_movement.asm): an
-- edge warp is only taken while its own direction is actually on the d-pad,
-- so walking out of a lift needs a real hold rather than a tap.
local function hold(btn, frames)
for _ = 1, (frames or 1) do
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
end
-- Gold runs on the engine's own src/core/StateStack.lua, the same stack
-- Gen 1 uses (src/core/Game2.lua:makeStack), so ask it for the top rather
-- than indexing a field.
local function top()
return game.stack and game.stack:top()
end
-- Run a script by key and step the world until it parks on something.
local function runUntilIdle(limit)
for _ = 1, (limit or 400) do
if not w:busy() then return true end
coroutine.yield()
end
return false
end
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
wait(45)
-- ---------------------------------------------------------------- 1. menu
--
-- CeladonDeptStore6F / GoldenrodDeptStore6F's vending machine is
-- `opentext / writetext / special PlaceMoneyTopRight / loadmenu / verticalmenu`,
-- and the `ifequal 1..3` ladder after it is what buys a drink. With no
-- header there was nothing to open and the script fell to the cancel arm.
--
-- The pair is run as an INLINE command list (Vm:start takes one) rather than
-- by starting the whole vending-machine script: everything in front of the
-- menu is text boxes and a money panel, and none of that is what this is
-- about. The two commands are the cache's own, lifted out of a real site.
local menuCmds
for key, cmds in pairs(w.scripts) do
if type(cmds) == "table" and key ~= "movements" and not menuCmds then
for i, cmd in ipairs(cmds) do
if cmd.op == "loadmenu" and cmd.menu and cmd.menu.items
and cmds[i + 1] and cmds[i + 1].op == "verticalmenu" then
menuCmds = { cmd, cmds[i + 1], { op = "end" } }
break
end
end
end
end
if ok(menuCmds ~= nil, "the cache has a loadmenu site with a real header") then
w:setMap("GOLDENROD_DEPT_STORE_6F", 5, 5, "up")
wait(20)
ok(w.vm:start(menuCmds), "the VM took the loadmenu / verticalmenu pair")
local opened = false
for _ = 1, 60 do
local state = top()
if state and state.screenId == "Gen2ScriptMenu" then opened = true break end
coroutine.yield()
end
ok(opened, "the vending machine opened its menu")
local menu = top()
if opened then
ok(#menu.items >= 2,
("with %d items off the extracted header"):format(#menu.items))
game.capturePath = SHOT_DIR .. "/menu.png"
wait(2)
-- Walk to the last row and pick it: CANCEL, the arm the script used to
-- take by default. Picking it deliberately proves the cursor moves and
-- the answer is the 1-based index rather than a stuck 0.
local want = #menu.items
for _ = 1, want do tap("down") end
ok(menu.row == want,
("the cursor reached row %d (got %d)"):format(want, menu.row))
tap("a")
wait(4)
ok(top() ~= menu, "and choosing closed it")
ok(w.vm.scriptVar == want,
("wScriptVar is the 1-based choice %d (got %s)")
:format(want, tostring(w.vm.scriptVar)))
end
runUntilIdle(200)
end
-- ------------------------------------------------------------ 2. elevator
--
-- Elevator writes wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber and
-- rides nowhere; the elevator's own door -- a warp_event whose destination
-- warp is -1 -- is what carries the player out onto the chosen floor.
-- Pick GOLDENROD'S list by the floor it names, not by whichever `elevator`
-- pairs() reaches first: the three lists are per-building, and running
-- Celadon's from Goldenrod's lift is the .FindCurrentFloor miss that quits
-- with no menu at all.
local FLOOR = "GOLDENROD_DEPT_STORE_1F"
local elevatorCmds
for key, cmds in pairs(w.scripts) do
if type(cmds) == "table" and key ~= "movements" and not elevatorCmds then
for _, cmd in ipairs(cmds) do
for _, floor in ipairs((cmd.op == "elevator" and cmd.floors) or {}) do
if floor.destMap == FLOOR then
elevatorCmds = { cmd, { op = "end" } }
break
end
end
if elevatorCmds then break end
end
end
end
if ok(elevatorCmds ~= nil, "the cache has an elevator with a floor list") then
-- Arrive the way a player does, so wBackupMapNumber is the floor they got
-- in on -- .FindCurrentFloor answers `scf` and skips the whole thing
-- otherwise.
w:setMap(FLOOR, 4, 2, "up")
wait(10)
local door
for _, warp in ipairs(w.map.def.warps or {}) do
if warp.destMap == "GOLDENROD_DEPT_STORE_ELEVATOR" then door = warp end
end
if ok(door ~= nil, "1F has a door into the elevator") then
w:takeWarp(door)
runUntilIdle(300)
wait(20)
ok(w.map.id == "GOLDENROD_DEPT_STORE_ELEVATOR",
("the player is in the elevator (got %s)"):format(w.map.id))
ok(w.backupMapId == FLOOR,
("and came in from 1F (got %s)"):format(tostring(w.backupMapId)))
-- The door is the thing under test: a `warp_event` whose destination
-- warp is -1 names no floor of its own.
local door_ = nil
for _, warp in ipairs(w.map.def.warps or {}) do
if warp.destWarp == 0xff then door_ = warp end
end
ok(door_ ~= nil, "the elevator's own door is a -1 warp")
ok(door_ and w:resolveWarp(door_) == FLOOR,
"which resolves to the floor we came in on until a ride is picked")
ok(w.vm:start(elevatorCmds), "the VM took the elevator command")
local opened = false
for _ = 1, 60 do
local state = top()
if state and state.screenId == "Gen2ElevatorMenu" then opened = true break end
coroutine.yield()
end
ok(opened, "the elevator opened its floor list")
local lift = top()
if opened then
ok(lift.origin ~= nil, "with the floor it came in on marked")
game.capturePath = SHOT_DIR .. "/elevator.png"
wait(2)
-- Ride to the top floor of the list, which is never the one we are on.
for _ = 1, #lift.floors do tap("down") end
local target = lift.floors[lift.index]
tap("a")
wait(4)
ok(w.backupWarp ~= nil and w.backupWarp.map == target.destMap,
("Elevator_GoToFloor stored %s"):format(tostring(target.destMap)))
-- The SAME door now resolves somewhere else, which is the whole of
-- what the ride is: nothing about the map changed.
ok(door_ and w:resolveWarp(door_) == target.destMap,
("and the door now resolves to %s"):format(
tostring(target.destMap)))
-- Elevator_GoToFloor rides nowhere: the player still has to walk out
-- through the door, which is the edge warp the -1 destination is on.
hold("down", 40)
runUntilIdle(300)
wait(20)
ok(w.map.id == target.destMap,
("walking out opens on %s (got %s)"):format(target.destMap,
w.map.id))
end
end
end
-- --------------------------------------------------------- 3. phone call
--
-- SPECIALCALL_ROBBED is Elm's "your POKeMON was stolen" beat. Its script is
-- ElmPhoneCallerScript at 41:41e1, reached only because the extractor seeds
-- its queue from PhoneContacts.
local Phone = require("src.core.gen2.Phone")
local key = Phone.SCRIPT_KEYS.ElmPhoneCallerScript
ok(w.scripts[key] ~= nil,
("ElmPhoneCallerScript (%s) is in scripts.lua"):format(tostring(key)))
do
-- The condition is SpecialCallOnlyWhenOutside, so stand in a town.
w:setMap("NEW_BARK_TOWN", 5, 8, "down")
wait(20)
Phone.queueSpecialCall(game.save, Phone.SPECIALCALL.SPECIALCALL_ROBBED)
local call = Phone.checkSpecialCall(game.save, {
map = w.map.def, maps = w.maps, daytime = w.daytime,
environment = w.map.def and w.map.def.environment,
})
if ok(call ~= nil, "CheckSpecialPhoneCall produced a call outdoors") then
ok(call.scriptKey == key,
("aimed at the caller script (%s)"):format(tostring(call.scriptKey)))
local before = w.unrunnableCalls or 0
local ran = w:receivePhoneCall(call)
ok(ran, "and the world RAN it rather than dropping it")
ok((w.unrunnableCalls or 0) == before,
"so nothing was counted as unrunnable")
game.capturePath = SHOT_DIR .. "/phonecall.png"
wait(2)
for _ = 1, 300 do
if not w:busy() then break end
tap("a")
end
-- The script's own first act is `specialphonecall SPECIALCALL_NONE`.
ok(not Phone.hasSpecialCall(game.save),
"and the script cleared the queue on its way out")
end
end
-- --------------------------------------------------------------- 4. trade
--
-- NPC_TRADE_MIKE: hand over a DROWZEE, get MACHOP nicknamed MUSCLE.
local NpcTrade = require("src.core.gen2.NpcTrade")
local row = NpcTrade.row(w.eventTables, 0)
if ok(row ~= nil, "the cache carries the six in-game trades") then
local Mon = require("src.battle.gen2.Mon")
game.save.party = { Mon.new(game.data, row.give, 20) }
game.save.tradeFlags = {}
ok(game.save.party[1] ~= nil,
("a level 20 %s in the party"):format(tostring(row.give)))
w:openNpcTrade(0, function() end)
local trade = top()
if ok(trade and trade.screenId == "Gen2TradeMenu", "the trade opened") then
game.capturePath = SHOT_DIR .. "/trade.png"
wait(2)
-- Page to the yes/no, answer YES, then pick the only party member.
for _ = 1, 30 do
if trade.confirm and trade.confirm.page >= #trade.confirm.pages then
break
end
tap("a")
end
tap("a") -- YES
wait(4)
local party = top()
if ok(party and party.screenId == "Gen2PartyMenu",
"and it opened the party list") then
tap("a")
wait(4)
for _ = 1, 60 do
if not top() or top() == trade then break end
tap("a")
end
for _ = 1, 60 do
if trade.closed then break end
tap("a")
end
end
local got = game.save.party[1]
ok(got and got.species == row.get,
("the party now holds %s (got %s)"):format(tostring(row.get),
tostring(got and got.species)))
ok(got and got.nickname == row.nickname,
("nicknamed %s (got %s)"):format(tostring(row.nickname),
tostring(got and got.nickname)))
ok(got and got.otName == row.otName,
("with OT %s"):format(tostring(row.otName)))
ok(got and got.level == 20, "at the level of the mon handed over")
ok(NpcTrade.done(game.save, 0), "and the trade's flag is set")
end
end
if fails > 0 then
error(("gold extractor pass: %d assertion(s) failed"):format(fails), 0)
end
print("[driver] PASS gold extractor pass in " .. SHOT_DIR)
end
+180
View File
@@ -0,0 +1,180 @@
-- The field presentations a test cannot see, through the real world:
-- teleport_from's spin-and-rise, the fishing rod bob, the headbutt tree shake,
-- FLY's take-off lift, and FLY's own destination picker.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_field_anim_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-field (default)
--
-- Every beat is asserted as well as shot, so a run that only prints PASS is
-- still worth something on a machine nobody is looking at.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Movement = require("src.script.gen2.Movement")
local Pokegear = require("src.ui.gen2.Pokegear")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-field"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[field] ok " .. label)
else
failures = failures + 1
print("[field] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world:setMap("ROUTE_30", 10, 10, "down")
U.wait(10)
-- ------------------------------------------------------------- teleport
--
-- LakeOfRageLanceTeleportIntoSkyMovement is `teleport_from / step_end`:
-- StepFunction_TeleportFrom spins the object for sixteen frames and then
-- lifts it off the tile over sixteen more. The byte used to decode as a nop,
-- so Lance simply blinked out.
local npc = world.npcs and world.npcs[1]
-- applymovement's operand is the object_const_def index, which starts at 2
-- (World:objectEntity takes it back off), not the pooled NPC's own id.
local objectId = npc and npc.def and npc.def.index and (npc.def.index + 1)
if npc and objectId then
local finished = false
world:beginMovement(objectId,
{ Movement.TELEPORT_FROM, Movement.STEP_END },
function() finished = true end)
local facings, deepest = {}, 0
for frame = 1, 34 do
U.wait(1)
facings[npc.facing] = true
deepest = math.min(deepest, npc.spriteYOffset or 0)
if frame == 20 then U.shot(game, out .. "/01-teleport-spin.png") end
if frame == 30 then U.shot(game, out .. "/02-teleport-rise.png") end
end
ok("teleport_from spins the object",
facings.up and facings.down and facings.left and facings.right)
ok("and lifts it off its tile", deepest <= -0x50, deepest)
ok("and the movement stream waits it out", finished)
ok("and the object never left its cell", npc.spriteYOffset == 0)
else
print("[field] SKIP teleport: no object on this map")
end
-- ------------------------------------------------------------- fishing
--
-- Script_GotABite's four fish_got_bite bobs: StepFunction_GotBite flips
-- OBJECT_SPRITE_Y_OFFSET between 0 and 1 once a frame.
local Mon = require("src.battle.gen2.Mon")
local wild = Mon.new(game.data, "MAGIKARP", 10)
world:beginFishing("battle", wild)
local offsets = {}
for frame = 1, 90 do
U.wait(1)
offsets[world.player.spriteYOffset or 0] = true
if frame == 40 then U.shot(game, out .. "/03-fishing.png") end
if world.textbox then break end
end
ok("the rod bobs the player one pixel", offsets[1] == true)
world.fishing = nil
world.player.spriteYOffset = 0
U.wait(5)
while game.stack:top() do game.stack:pop() end
U.wait(5)
-- ------------------------------------------------------------- headbutt
--
-- ShakeHeadbuttTree runs a 32-frame wobble under SFX_SANDSTORM.
world:runHeadbutt(10, 9, { species = "SPEAROW", nickname = "SPEAROW" })
U.wait(2)
-- The line is a text box; A takes it down and the shake starts on its close.
for _ = 1, 20 do
if world.headbutt then break end
U.tap(game, "a")
U.wait(2)
end
ok("the headbutt shake is armed", world.headbutt ~= nil)
ok("and the frame shakes with it", world.shake ~= nil)
U.wait(4)
U.shot(game, out .. "/04-headbutt.png")
for _ = 1, 120 do
if not world.headbutt then break end
U.tap(game, "a")
U.wait(2)
end
while game.stack:top() do game.stack:pop() end
U.wait(5)
-- ------------------------------------------------------------- fly
--
-- Every flypoint visited, so the picker has a full map to walk.
local save = game.save
save.engineFlags = save.engineFlags or {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
save.engineFlags[row.flag] = true
end
ok("openFlyMap opens a screen", world:openFlyMap() == true)
U.wait(4)
local picker = game.stack:top()
ok("and it is the town-map picker, not a yes/no box",
getmetatable(picker) == Pokegear and picker.fly ~= nil)
ok("with the cursor on a flypoint",
picker and picker.flyRow and picker:flyRow() ~= nil)
U.shot(game, out .. "/05-flymap.png")
U.tap(game, "up")
U.wait(4)
U.shot(game, out .. "/06-flymap-moved.png")
-- A takes the destination: the fade out lifts the player off the map first.
U.tap(game, "a")
local lifted = 0
for frame = 1, 40 do
U.wait(1)
lifted = math.min(lifted, (world.player and world.player.spriteYOffset) or 0)
if frame == 4 then U.shot(game, out .. "/07-fly-takeoff.png") end
end
ok("FLY lifts the player under the fade", lifted < 0, lifted)
U.wait(30)
ok("and lands them back on the tile",
(world.player.spriteYOffset or 0) == 0)
U.shot(game, out .. "/08-fly-landed.png")
-- --------------------------------------------------- tilt and the void
--
-- Zoomed out with TILT on is where both nitpicks live: the billboard clip
-- (no NPCs standing past where the ground is drawn) and the border-block
-- dissolve across a map boundary.
local Tilt = require("src.render.Tilt")
local Zoom = require("src.render.Zoom")
Zoom.offset = -3
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
Tilt.setLevel(3)
for _ = 1, 40 do
Tilt.update(1 / 60)
U.wait(1)
end
U.shot(game, out .. "/09-tilt-survey.png")
-- Cross into the next map: the void fill dissolves from one border block to
-- the other rather than cutting.
world:setMap("ROUTE_31", 10, 10, "down")
U.wait(2)
U.shot(game, out .. "/10-void-crossfade.png")
U.wait(10)
U.shot(game, out .. "/11-void-settled.png")
ok("the border fill is mid-dissolve on arrival",
world.borderFade == nil or world.borderFade >= 1)
Tilt.setLevel(0)
Zoom.offset = 0
if failures > 0 then
print(("[driver] FAIL gold field anims: %d check(s)"):format(failures))
return
end
print("[driver] PASS gold field anims in " .. out)
end
+251
View File
@@ -0,0 +1,251 @@
-- Assertion driver: field item use from the PACK, end to end in the running
-- game. It PASSES or it errors; there is nothing to eyeball.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_field_items.lua love .
--
-- tests/gen2_field_items_test.lua proves the effects and the menu wiring over
-- fixtures and a recording setMap; what it cannot prove is the whole loop --
-- a real START press, the real PACK over the real overworld, the queued
-- escape warp riding a genuine map load, and the SELECT box tearing down to
-- an empty stack. So this drives everything with button taps:
--
-- 1. bank the escape triple by taking Cherrygrove's Pokecenter stairs,
-- then use an ESCAPE ROPE from the PACK inside Union Cave B2F and land
-- back on the banked staircase (engine/events/overworld.asm
-- EscapeRopeOrDig, via the -1 backup triple this port banks);
-- 2. use a POTION from the PACK on a hurt party mon through the real
-- "Use on which <PK><MN>?" list (pack.asm UseItem .Party);
-- 3. an X ATTACK from the field PACK prints OakThisIsntTheTimeText and
-- stays in the PACK (UseItem's .Oak arm);
-- 4. the SELECT MayRegisterItemText box pages and dismisses without
-- leaving anything on the stack.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
save.party = { Mon.new(game.data, "CYNDAQUIL", 12) }
assert(save.party[1], "the cache carries no CYNDAQUIL to seed a party")
local function tapUntil(predicate, tries, btn)
for _ = 1, tries or 300 do
if predicate() then return true end
U.tap(game, btn or "a")
U.wait(2)
end
return predicate()
end
-- START, then walk the cursor to the PACK row and open it.
local function openPack()
U.tap(game, "start")
U.wait(3)
local menu = game.stack:top()
assert(menu and menu.list, "START menu did not open")
local guard = 0
while menu.list:current().value ~= "pack" do
U.tap(game, "down")
U.wait(2)
guard = guard + 1
assert(guard < 12, "no PACK row in the START menu")
end
U.tap(game, "a")
U.wait(3)
local pack = game.stack:top()
assert(pack and pack.rows, "PACK did not open")
return pack
end
-- ---- 1. the escape rope --------------------------------------------------
-- Bank the triple the way play does: up the Cherrygrove stairs, whose
-- arrival warp declares -1 (tests/gen2_pokecenter_stairs_test.lua owns the
-- banking rules; this run just rides them).
assert(world:setMap("CHERRYGROVE_POKECENTER_1F", 3, 4, "down"),
"setMap CHERRYGROVE_POKECENTER_1F failed")
U.wait(5)
local stairs = world.maps.CHERRYGROVE_POKECENTER_1F.warps[3]
world.player.cellX, world.player.cellY = stairs.x, stairs.y
assert(world:takeWarp(stairs), "the Pokecenter stairs refused")
for _ = 1, 300 do
if world.map.id == "POKECENTER_2F" and not world.mapSetup then break end
U.wait(1)
end
assert(world.map.id == "POKECENTER_2F", "did not arrive on POKECENTER_2F")
assert(world.backupWarp
and world.backupWarp.map == "CHERRYGROVE_POKECENTER_1F",
"the -1 arrival did not bank the Cherrygrove triple")
assert(world:setMap("UNION_CAVE_B2F", 5, 3, "down"),
"setMap UNION_CAVE_B2F failed")
U.wait(5)
save.inventory = { ESCAPE_ROPE = 1 }
local pack = openPack()
assert(pack.rows[1] and pack.rows[1].id == "ESCAPE_ROPE",
"the PACK does not show the ESCAPE ROPE")
-- A picks the row and opens the item submenu
-- (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243); USE is its first
-- row, so using an item from the field PACK is two presses.
U.tap(game, "a")
U.wait(2)
assert(pack.submenu, "A on a field PACK row did not open the item submenu")
U.tap(game, "a")
U.wait(3)
assert(game.stack:top() ~= pack,
"using the rope must quit the PACK (PACKSTATE_QUITRUNSCRIPT)")
-- The queued script: the used-rope line over the overworld, then the warp.
tapUntil(function()
return game.stack:top() == nil and not world.mapSetup
and world.map.id ~= "UNION_CAVE_B2F"
end)
assert(world.map.id == "CHERRYGROVE_POKECENTER_1F",
"the rope did not pay out to the banked centre (on "
.. tostring(world.map.id) .. ")")
assert(world.player.cellX == stairs.x and world.player.cellY == stairs.y,
"the rope landed off the banked staircase tile")
assert(save.inventory.ESCAPE_ROPE == nil, "the rope was not consumed")
U.log("PASS escape rope: Union Cave B2F -> Cherrygrove stairs, consumed")
-- ---- 2. a POTION on a party mon -----------------------------------------
local mon = save.party[1]
mon.hp = 5
save.inventory = { POTION = 1 }
local healPack = openPack()
assert(healPack.rows[1] and healPack.rows[1].id == "POTION",
"the PACK does not show the POTION")
U.tap(game, "a") -- the submenu
U.wait(2)
U.tap(game, "a") -- USE
U.wait(3)
local party = game.stack:top()
assert(party and party.prompt, "USE did not open the party list")
U.tap(game, "a")
U.wait(3)
tapUntil(function() return game.stack:top() == healPack end)
assert(game.stack:top() == healPack,
"the heal message did not return to the PACK")
assert(mon.hp == 25, "POTION healed to " .. tostring(mon.hp) .. ", want 25")
assert(save.inventory.POTION == nil, "the POTION was not consumed")
U.tap(game, "b")
U.wait(2)
U.tap(game, "b")
U.wait(2)
assert(game.stack:top() == nil, "the menus did not unwind after the heal")
U.log("PASS potion: healed a party mon from the PACK, consumed")
-- ---- 3. the .Oak refusal -------------------------------------------------
-- A tossable field-NOUSE item is MenuHeader_HoldableItem: GIVE / TOSS /
-- QUIT, with no USE row at all -- the cart refuses an X ATTACK in the field
-- by never offering the verb.
save.inventory = { X_ATTACK = 1 }
local oakPack = openPack()
U.tap(game, "a")
U.wait(2)
assert(oakPack.submenu, "A on the X ATTACK opened no submenu")
assert(table.concat(oakPack.submenu.rows, ",") == "give,toss,quit",
"the X ATTACK submenu is " .. table.concat(oakPack.submenu.rows, ","))
U.tap(game, "b")
U.wait(2)
assert(save.inventory.X_ATTACK == 1, "backing out must not spend the item")
U.tap(game, "b")
U.wait(2)
U.tap(game, "b")
U.wait(2)
assert(game.stack:top() == nil, "the menus did not unwind after the submenu")
-- ...and an untossable one still gets USE, because .ItemBallsKey_LoadSubmenu's
-- untossable arm never looks at the menu nibble -- so THAT is where
-- OakThisIsntTheTimeText is still reachable in the field.
save.inventory = { SECRETPOTION = 1 }
local keyPack = openPack()
while keyPack:pocket().id ~= "KEY_ITEM" do
U.tap(game, "right")
U.wait(2)
end
U.tap(game, "a")
U.wait(2)
assert(table.concat(keyPack.submenu.rows, ",") == "use,quit",
"the SECRETPOTION submenu is " .. table.concat(keyPack.submenu.rows, ","))
U.tap(game, "a")
U.wait(2)
assert(keyPack.message, "the key item printed no Oak line")
assert(game.stack:top() == keyPack, "the refusal must keep the PACK open")
assert(save.inventory.SECRETPOTION == 1,
"the refusal must not spend the item")
U.tap(game, "a")
U.wait(2)
U.tap(game, "b")
U.wait(2)
U.tap(game, "b")
U.wait(2)
assert(game.stack:top() == nil, "the menus did not unwind after the refusal")
U.log("PASS oak: field-NOUSE key item refused inside the PACK")
-- ---- 4. the SELECT box ---------------------------------------------------
U.tap(game, "select")
U.wait(3)
local box = game.stack:top()
assert(box and box.pages, "SELECT with nothing registered opened no box")
assert(#box.pages == 2, "MayRegisterItemText must page: got "
.. tostring(#box.pages))
tapUntil(function() return game.stack:top() == nil end)
assert(game.stack:top() == nil, "the SELECT box did not tear down")
-- And nothing half-dismissed lingers: a second press opens a fresh one.
U.tap(game, "select")
U.wait(3)
assert(game.stack:top() ~= nil, "the second SELECT box did not open")
tapUntil(function() return game.stack:top() == nil end)
U.log("PASS select: MayRegisterItemText pages and tears down cleanly")
-- ---- 5. DIG through the party submenu -----------------------------------
-- The same escape consumer off the MONMENU_FIELD_MOVE row: the triple is
-- still banked from the stairs above.
local mon2 = save.party[1]
mon2.moves = { { id = "DIG", pp = 10, maxPp = 10 } }
assert(world:setMap("UNION_CAVE_B2F", 5, 3, "down"),
"setMap UNION_CAVE_B2F failed for DIG")
U.wait(5)
U.tap(game, "start")
U.wait(3)
local menu = game.stack:top()
assert(menu and menu.list, "START menu did not open for DIG")
local guard = 0
while menu.list:current().value ~= "pokemon" do
U.tap(game, "down")
U.wait(2)
guard = guard + 1
assert(guard < 12, "no POKeMON row in the START menu")
end
U.tap(game, "a")
U.wait(3)
local list = game.stack:top()
assert(list and list.wantsSubmenu, "the field party list did not open")
U.tap(game, "a")
U.wait(2)
assert(list.submenu, "the mon submenu did not open")
assert(list.submenu.items[1] and list.submenu.items[1].id == "DIG",
"DIG is not the submenu's field-move row")
U.tap(game, "a")
U.wait(3)
tapUntil(function()
return game.stack:top() == nil and not world.mapSetup
and world.map.id ~= "UNION_CAVE_B2F"
end)
assert(world.map.id == "CHERRYGROVE_POKECENTER_1F",
"DIG did not pay out to the banked centre (on "
.. tostring(world.map.id) .. ")")
U.log("PASS dig: the party submenu row escapes to the banked warp")
U.log("PASS gold_field_items")
love.event.quit()
end
+67
View File
@@ -0,0 +1,67 @@
-- Red Gyarados BATTLETYPE_FORCESHINY probe: the REAL script chain (the
-- RedGyarados object script at 49:4f6f -> loadwildmon GYARADOS, 30 ->
-- loadvar VAR_BATTLETYPE, BATTLETYPE_FORCESHINY -> startbattle) must hand
-- the battle a SHINY Gyarados (InitEnemyMon `.NotRoaming`, engine/battle/
-- core.asm:5876: DVs $EA/$AA) and a battle RUN cannot leave
-- (TryToRunAwayFromBattle's .cant_escape arm for the type).
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end
local ok, err = A.loadCheckpoint(game, "10")
if not ok then
print("[forceshiny] FAIL resume: " .. tostring(err))
return
end
for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end
if not bot:travelTo("LAKE_OF_RAGE") then
print("[forceshiny] FAIL travel")
return
end
if not bot:approachAndFace(18, 22) then
print("[forceshiny] FAIL approach gyarados")
return
end
bot:tap("a")
local battle
for _ = 1, 2400 do
if A.inBattle(game) then
battle = A.top(game).battle
break
end
bot:tap("a")
bot:wait(2)
end
if not battle then
print("[forceshiny] FAIL no battle came up")
return
end
local wild = battle.enemy or {}
local dvs = wild.dvs or {}
print(("[forceshiny] map=%s pos=%s,%s wild=%s trainer=%s level=%s")
:format(tostring(A.mapId(game)), tostring(select(1, A.pos(game))),
tostring(select(2, A.pos(game))), tostring(battle.wild),
tostring(battle.trainer and battle.trainer.name), tostring(wild.level)))
print(("[forceshiny] battleType=%s species=%s shiny=%s dvs=%s/%s/%s/%s")
:format(tostring(battle.battleType), tostring(wild.species),
tostring(wild.shiny), tostring(dvs.attack), tostring(dvs.defense),
tostring(dvs.speed), tostring(dvs.special)))
local pass = battle.battleType == 7
and wild.species == "GYARADOS"
and wild.shiny == true
and dvs.attack == 14 and dvs.defense == 10
and dvs.speed == 10 and dvs.special == 10
-- RUN must refuse and leave the encounter live.
local ran = battle:tryRun()
print(("[forceshiny] tryRun=%s over=%s")
:format(tostring(ran), tostring(battle.over)))
if ran or battle.over then pass = false end
print(pass and "[forceshiny] PASS" or "[forceshiny] FAIL")
end
+240
View File
@@ -0,0 +1,240 @@
-- Gold's frame and input seams: proof that all six shared hooks fire on a Gold
-- boot, with the Gen 1 payloads, and that subscribing to them does not change
-- the picture.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_frame_seams.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-seams love .
--
-- Two halves, and the second is the one that matters. A hook that fires is
-- easy; a hook that fires and MOVES A PIXEL is worse than no hook at all,
-- because it silently changes what every existing Gold screenshot means. So
-- this shoots the overworld, a menu over the overworld, CLASSIC, and CLASSIC +
-- GBC FX with nothing subscribed, wraps all six hooks with pass-throughs that
-- draw nothing, shoots the same four frames again, and compares the PNG bytes.
-- Identical files are the claim; the shots are left on disk either way so a
-- human can look at the picture the port is actually producing.
--
-- The four frames are chosen to cover every branch of Game2:draw: no canvas at
-- all, the zone pass alone, the zone pass into a texture GBC FX then reads, and
-- (once the wraps are on) the render.compose path that forces a canvas even
-- when no display mode wanted one.
local U = require("tests.drivers.util")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local GbcPalette = require("src.render.GbcPalette")
local GBCFX = require("src.render.GBCFX")
local OWNER = "driver_frame_seams"
local function readFile(path)
local f = io.open(path, "rb")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-seams"
local failures = 0
local function check(ok, what)
if ok then
print("[driver] ok " .. what)
else
failures = failures + 1
print("[driver] FAIL " .. what)
end
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- The four frames, each named for the display state it exercises. `fx` is
-- requested rather than asserted: GBCFX.isSupported refuses on mobile GPUs
-- and a headless checkout may have no shader at all, in which case that
-- frame simply repeats the CLASSIC one and the comparison still means
-- something.
local frames = {
{ name = "world", color = "gbc", fx = 0 },
{ name = "classic", color = "classic", fx = 0 },
{ name = "classicfx", color = "classic", fx = 2 },
{ name = "menu", color = "gbc", fx = 0, menu = true },
}
local function shoot(tag)
local menuOpen = false
for _, frame in ipairs(frames) do
GbcPalette.setMode(frame.color)
GBCFX.setLevel(frame.fx)
if frame.menu and not menuOpen then
game:openStartMenu()
menuOpen = true
U.wait(6)
end
U.wait(2)
U.shot(game, ("%s/%s-%s.png"):format(out, frame.name, tag))
end
if menuOpen then
-- B closes the START menu; leave the stack the way it was found so the
-- second pass shoots the same scene the first one did
U.tap(game, "b")
U.wait(8)
end
GbcPalette.setMode("gbc")
GBCFX.setLevel(0)
end
-- POKEPORT_SEAM_BASELINE=<tag> shoots the four frames under that tag and
-- stops, with nothing ever subscribed. That is how the composite was A/B'd
-- against the revision of Game2:draw that predates these seams: run it once
-- with the old draw in place, once with the new one, and diff the PNGs.
local baseline = os.getenv("POKEPORT_SEAM_BASELINE")
if baseline and baseline ~= "" then
shoot(baseline)
print(("[driver] baseline shots (%s) in %s"):format(baseline, out))
return
end
shoot("before")
-- A live bus, in case this checkout booted with no mods (Runtime's null
-- object has no chains table and wantsHook is false for everything).
if not (Runtime.hooks and Runtime.hooks.wrap) then
Runtime.hooks = Hooks.new()
end
local hooks = Runtime.hooks
local seen = {}
local payload = {}
local function record(name, ctx)
seen[name] = (seen[name] or 0) + 1
payload[name] = payload[name] or ctx
end
-- input.step: (game, dt), before the pad is read
hooks:wrap("input.step", function(nextFn, g, dt)
record("input.step", { game = g, dt = dt })
return nextFn(g, dt)
end, 0, OWNER)
-- input.pointer: (game, event); returning false is "not consumed"
hooks:wrap("input.pointer", function(nextFn, g, ev)
record("input.pointer", { game = g, ev = ev })
return nextFn(g, ev)
end, 0, OWNER)
-- render.zones: (game, zones) -> zones. Pass the list straight through; a
-- wrap that returned a new list would be testing itself, not the seam.
hooks:wrap("render.zones", function(nextFn, g, zones)
record("render.zones", { game = g, zones = zones })
return nextFn(g, zones)
end, 0, OWNER)
-- render.compose: (renderer, ctx) -> true to take the window. Declines, so
-- the engine composite still runs -- which is the case the byte comparison
-- below is about.
hooks:wrap("render.compose", function(nextFn, r, ctx)
record("render.compose", { renderer = r, ctx = ctx })
return nextFn(r, ctx)
end, 0, OWNER)
-- render.letterbox: (ctx), draws nothing
hooks:wrap("render.letterbox", function(nextFn, ctx)
record("render.letterbox", { ctx = ctx })
return nextFn(ctx)
end, 0, OWNER)
-- render.hud: (game, viewport), draws nothing
hooks:wrap("render.hud", function(nextFn, g, viewport)
record("render.hud", { game = g, viewport = viewport })
return nextFn(g, viewport)
end, 0, OWNER)
U.wait(4)
-- a pointer the engine itself never generates headlessly
game:mousepressed(40, 30, 1, false)
game:mousemoved(48, 36, 8, 6, false)
game:mousereleased(48, 36, 1, false)
U.wait(2)
shoot("after")
-- ---- the seams fired, with the Gen 1 payloads -----------------------------
for _, name in ipairs({ "input.step", "input.pointer", "render.zones",
"render.compose", "render.letterbox",
"render.hud" }) do
check((seen[name] or 0) > 0, name .. " fires on Gold (" ..
tostring(seen[name] or 0) .. " calls)")
end
local step = payload["input.step"]
check(step and step.game == game, "input.step receives the live Game object")
check(step and math.abs((step.dt or 0) - 1 / 60) < 1e-9,
"input.step receives the fixed-step dt")
local ptr = payload["input.pointer"]
check(ptr and ptr.game == game, "input.pointer receives the live Game object")
check(ptr and ptr.ev and ptr.ev.phase == "pressed"
and ptr.ev.source == "mouse" and ptr.ev.id == "mouse"
and ptr.ev.x == 40 and ptr.ev.y == 30 and ptr.ev.button == 1
and ptr.ev.dx == 0 and ptr.ev.dy == 0,
"input.pointer carries phase/source/id/x/y/dx/dy/button")
local zones = payload["render.zones"]
check(zones and zones.game == game, "render.zones receives the live Game")
local hud = payload["render.hud"]
local vp = hud and hud.viewport
local ww, wh = love.graphics.getDimensions()
check(vp and vp.width == ww and vp.height == wh,
"render.hud viewport carries the window size")
check(vp and vp.gameWidth == 160 * vp.scale
and vp.gameHeight == 144 * vp.scale,
"render.hud viewport playfield is 160x144 at the fit scale")
check(vp and vp.gameX == math.floor((ww - vp.gameWidth) / 2)
and vp.gameY == math.floor((wh - vp.gameHeight) / 2),
"render.hud viewport playfield is centred")
check(vp and vp.dpiX ~= nil and vp.dpiY ~= nil,
"render.hud viewport carries the dpi scales")
local lb = payload["render.letterbox"] and payload["render.letterbox"].ctx
check(lb and lb.ww == ww and lb.wh == wh,
"render.letterbox carries ww/wh")
check(lb and lb.pw ~= nil and lb.ph ~= nil and lb.ox ~= nil and lb.oy ~= nil
and lb.vpw ~= nil and lb.vph ~= nil and lb.scale ~= nil
and lb.dpiX ~= nil and lb.dpiY ~= nil,
"render.letterbox carries pw/ph/ox/oy/vpw/vph/scale/dpi")
check(lb and type(lb.worldActive) == "boolean",
"render.letterbox carries worldActive")
local ctx = payload["render.compose"] and payload["render.compose"].ctx
check(payload["render.compose"]
and payload["render.compose"].renderer == game,
"render.compose receives the compositor in the renderer position")
check(ctx and ctx.uiCanvas ~= nil and ctx.worldCanvas == ctx.uiCanvas,
"render.compose hands over Gold's one scene canvas under both keys")
check(ctx and ctx.ww == ww and ctx.wh == wh and ctx.uiw == 160
and ctx.uih == 144 and ctx.scale ~= nil and ctx.ox ~= nil
and ctx.oy ~= nil and ctx.vpw ~= nil and ctx.vph ~= nil
and ctx.dpiX ~= nil and ctx.secondScreen ~= nil,
"render.compose carries the Gen 1 frame metrics")
-- ---- and moved nothing ----------------------------------------------------
for _, frame in ipairs(frames) do
local a = readFile(("%s/%s-before.png"):format(out, frame.name))
local b = readFile(("%s/%s-after.png"):format(out, frame.name))
check(a ~= nil and b ~= nil and a == b,
("subscribing does not change the %s frame"):format(frame.name))
end
hooks:removeOwner(OWNER)
print(("[driver] shots in %s"):format(out))
if failures > 0 then
print(("[driver] FAILED (%d)"):format(failures))
else
print("[driver] PASS")
end
end
+162
View File
@@ -0,0 +1,162 @@
-- FreezeAllOtherObjects (engine/overworld/scripting.asm:751-755): the FIRST
-- act of every `applymovement`, before it has even read the movement pointer.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_freeze_other_objects.lua love .
--
-- FreezeAllObjects sets FROZEN_F on every object struct and ApplyMovement then
-- clears it on the one being moved (engine/overworld/map_objects.asm:2529),
-- and nothing puts it back until EndScript's UnfreezeAllObjects. So from the
-- moment a Rocket grunt starts walking at you -- SeenByTrainerScript's
-- `applymovementlasttalked` (engine/events/trainer_scripts.asm:14) -- until
-- his after-battle line is done, NOBODY else on the floor turns.
--
-- RADIO_TOWER_4F is the test bench: DJ MARY's teacher at (14,6) is
-- SPRITEMOVEDATA_SPINRANDOM_SLOW (maps/RadioTower4F.asm:263) and stands well
-- clear of the grunt at (5,6), so she is a pure observer. She must roll new
-- facings before the trainer engages, hold ONE facing for the whole exchange,
-- and start rolling again once it is over.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local TEACHER, GRUNT = 2, 4 -- def.objects indices
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-freeze"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
game.save.party = { assert(Mon.new(game.data, "TYPHLOSION", 60)) }
-- Every Rocket on this floor carries EVENT_RADIO_TOWER_ROCKET_TAKEOVER
-- (maps/RadioTower4F.asm:265), which the story clears when Team Rocket moves
-- in. Read the flag off the object rather than naming a number.
world:setMap("RADIO_TOWER_4F", 10, 10, "up")
U.wait(5)
world.events:set(world.map.def.objects[4].eventFlag, false)
world:setMap("RADIO_TOWER_4F", 10, 10, "up")
U.wait(20)
-- (10,10) shares no row and no column with any sight cone on the floor. The
-- map's own stairs land at (5,9), three cells below the grunt at (5,6), who
-- faces DOWN with sight 3 -- land there with a party and the trainer script
-- is already running before the idle window opens.
local function obj(index)
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.index == index then return npc end
end
return nil
end
local teacher = obj(TEACHER)
local grunt = obj(GRUNT)
assert(teacher, "RADIO_TOWER_4F has no teacher at index " .. TEACHER)
assert(grunt, "RADIO_TOWER_4F has no grunt at index " .. GRUNT)
assert(teacher.def.movement == 3,
"the teacher is not SPINRANDOM_SLOW, got " .. tostring(teacher.def.movement))
-- Facing CHANGES over a window, plus any frame the object was holding
-- FROZEN_F. The facing count is the behaviour; the flag is the mechanism,
-- and it is the one that does not depend on a random re-roll picking a
-- different quarter (SPINRANDOM_SLOW holds 60-180 frames and may well roll
-- the same way twice).
local function watch(frames)
local changes, frozen, last = 0, 0, teacher.facing
for _ = 1, frames do
if teacher.facing ~= last then
changes = changes + 1
last = teacher.facing
end
if teacher.frozen then frozen = frozen + 1 end
U.wait(1)
end
return changes, frozen
end
-- Idle: nothing is frozen, because no script has run an applymovement.
local idleTurns, idleFrozen = watch(600)
print(("[driver] idle: %d facing changes, %d frozen frames")
:format(idleTurns, idleFrozen))
U.shot(game, out .. "/00-idle.png")
assert(idleFrozen == 0, "the teacher was frozen with no script running")
-- The freeze starts at the FIRST applymovement, not at the first command:
-- SeenByTrainerScript spends `showemote EMOTE_SHOCK, LAST_TALKED, 30` before
-- it walks (engine/events/trainer_scripts.asm:12-14), and on the cart the
-- floor is still live through the bubble. Latch the moment ApplyMovement
-- runs and only hold the port to the flag from there on.
local walked = false
local realBegin = world.beginMovement
world.beginMovement = function(self, objectId, bytes, onDone)
walked = true
return realBegin(self, objectId, bytes, onDone)
end
-- Engage: stand in the grunt's line and let the eyesight test fire.
grunt.facing = "down"
world.player.cellX, world.player.cellY = grunt.cellX, grunt.cellY + 3
world.player.px = world.player.cellX * 16
world.player.py = world.player.cellY * 16
local fired = false
for _ = 1, 120 do
if world:busy() then fired = true break end
world:checkTrainerBattle()
U.wait(1)
end
assert(fired, "the grunt never noticed the player")
-- Hold: every frame the world is busy, right through the battle and the
-- after-battle text.
local held = teacher.facing
local drift, busyFrames, thawed = 0, 0, 0
for _ = 1, 2400 do
local top = game.stack:top()
if top and top.battle then
for _ = 1, 900 do
if top.battle.over then break end
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(2)
game.input.state.a = false
U.wait(2)
end
end
if world:busy() then
busyFrames = busyFrames + 1
if walked then
if teacher.facing ~= held then drift = drift + 1 end
if not teacher.frozen then thawed = thawed + 1 end
else
held = teacher.facing
end
elseif busyFrames > 60 then
break
end
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(1)
game.input.state.a = false
U.wait(1)
end
print(("[driver] %d busy frames: %d facing changes, %d frames unfrozen")
:format(busyFrames, drift, thawed))
U.shot(game, out .. "/01-during.png")
assert(busyFrames > 60, "the exchange was too short to prove anything")
assert(thawed == 0,
("the teacher was unfrozen on %d of %d script frames; ApplyMovement's "
.. "FreezeAllOtherObjects holds every object but the one being moved")
:format(thawed, busyFrames))
assert(drift == 0,
"the teacher kept spinning through the trainer exchange")
-- Release: EndScript's UnfreezeAllObjects gives every object its movement
-- function back.
local afterTurns, afterFrozen = watch(600)
print(("[driver] after: %d facing changes, %d frozen frames")
:format(afterTurns, afterFrozen))
U.shot(game, out .. "/02-after.png")
assert(afterFrozen == 0,
"the teacher never came out of FROZEN_F; EndScript's UnfreezeAllObjects "
.. "gives every object its movement function back")
print("[driver] PASS gold freeze-all-other-objects in " .. out)
love.event.quit()
end
+99
View File
@@ -0,0 +1,99 @@
-- GiveItemScript (engine/overworld/scripting.asm:441-449) is ONE MapTextbox.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_giveitem_box.lua love .
--
-- `writetext .ReceivedItemText / iffalse .Full / waitsfx / specialsound /
-- waitbutton / itemnotify`: the received line and the "put it in the pocket"
-- line print into the SAME box, which the caller's `opentext` opened and the
-- caller's `closetext` closes. Nothing between them takes the box down.
--
-- This port draws a box per message, so the seam between them is where the
-- fidelity is: the second box has to go up inside the same frame the first
-- one pops. If a frame renders with an empty state stack in between, the box
-- visibly tears down and rebuilds AND Game2's play clock -- which only ticks
-- while the overworld is the top state (src/core/Game2.lua, wGameTimerPaused)
-- -- comes off pause for the length of the gap.
--
-- The run counts the bare-overworld frames between the two boxes and the
-- play-clock frames they cost, and shoots both pages.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-giveitem"
local function tap(button)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(2)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
game.save.playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 }
local function clockFrames()
local t = game.save.playTime
return ((t.hours * 60 + t.minutes) * 60 + t.seconds) * 60 + t.frames
end
-- `opentext / verbosegiveitem POTION, 1 / closetext / end`, the shape every
-- NPC hand-over in the game uses.
world.vm:start({
{ op = "opentext" },
{ op = "verbosegiveitem", args = { "POTION", 1 } },
{ op = "closetext" },
{ op = "end" },
})
-- Sampled every frame, and the A press goes in every sixth: a sample taken
-- only on press frames would step straight over the seam being measured.
local bare, boxes, seenFirst = 0, 0, false
local clockAtFirstBox, clockAtLastBox = nil, nil
local shots, pressIn = 0, 8
for _ = 1, 900 do
if not world:busy() and seenFirst then break end
local top = game.stack:top()
if top then
if not seenFirst then
seenFirst = true
clockAtFirstBox = clockFrames()
end
clockAtLastBox = clockFrames()
if shots < 2 and boxes % 12 == 6 then
shots = shots + 1
U.shot(game, ("%s/%02d-page.png"):format(out, shots))
end
boxes = boxes + 1
elseif seenFirst then
bare = bare + 1
end
pressIn = pressIn - 1
if pressIn <= 0 then
pressIn = 6
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(1)
game.input.state.a = false
else
U.wait(1)
end
end
local spent = (clockAtLastBox or 0) - (clockAtFirstBox or 0)
print(("[driver] %d frames with a box up, %d bare frames between the pages")
:format(boxes, bare))
print(("[driver] the play clock advanced %d frames across the exchange")
:format(spent))
assert(seenFirst, "no text box ever went up for the item")
assert(bare == 0,
("the overworld drew bare for %d frames between the two pages of one "
.. "GiveItemScript textbox"):format(bare))
assert(spent == 0,
("the play clock ran for %d frames while an item was being handed over")
:format(spent))
print("[driver] PASS gold giveitem single box in " .. out)
love.event.quit()
end
+147
View File
@@ -0,0 +1,147 @@
-- The end of the game, sampled: the Hall of Fame induction, the credits roll,
-- and the roster the PC shows afterwards.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_halloffame_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-hof (default)
--
-- Everything here is a cinematic, which is exactly what no assertion can
-- check: "does the backpic really sweep off to the left before the frontpic
-- comes back", "does the banner change mon on each CREDITS_SCENE", "does THE
-- END stay up after the last blank" are questions for eyes. So this stands
-- the real screens up on the real stack, lets them run at their own 60 Hz, and
-- lays each one out as a contact sheet.
--
-- Shots are named by the frame the screen has been running for and the phase
-- (or credits scene) it is in, so a file is directly comparable against
-- engine/events/halloffame.asm and engine/movie/credits.asm.
local U = require("tests.drivers.util")
local Core = require("src.core.gen2.HallOfFame")
local Credits = require("src.ui.gen2.Credits")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local Mon = require("src.battle.gen2.Mon")
-- The induction is 292 frames a mon, so a six-mon party runs about 1900
-- frames; the credits are about 4400. Half a second apiece keeps both
-- readable without an unusable number of files.
local INDUCT_INTERVAL = 20
local CREDITS_INTERVAL = 60
local LIMIT = 8000
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-hof"
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
local data = game.data
-- A champion's party. Mon.new is the ONE builder for a Gen 2 party member:
-- anything routed through Gen 1's Pokemon.new comes back with no moves,
-- because a Gen 2 moveset is levelMoves and Gen 1 reads level1Moves.
local roster = {
{ "TYPHLOSION", 50, "BLAZE" },
{ "LANTURN", 46, "SPARK" },
{ "AMPHAROS", 45, nil },
{ "UMBREON", 44, "DUSK" },
{ "SCIZOR", 43, nil },
{ "GYARADOS", 47, "RAGE" },
}
save.party = {}
for _, row in ipairs(roster) do
local mon = Mon.new(data, row[1], row[2], { nickname = row[3] })
if mon then
mon.otId = save.player and save.player.id or 12345
save.party[#save.party + 1] = mon
end
end
assert(#save.party > 0, "no party could be built from this cache")
save.player.name = save.player.name or "GOLD"
save.playTime = { hours = 42, minutes = 7, seconds = 0, frames = 0 }
-- ---- the induction ------------------------------------------------------
-- What the `halloffame` opcode does to the save before the screen opens.
-- `wasEntered` is the ALLOW_SKIPPING_CREDITS_F bit Credits wants: false the
-- first time, which is why a first-time champion cannot hurry the roll.
local entry, wasEntered = Core.induct(save, save.party)
U.log(("inducted: %d mon(s), win count %d, spawn %s")
:format(#entry.mons, entry.winCount, tostring(save.spawnAfterChampion)))
local inducted = false
local induction = HallOfFame.new(game, {
save = save, entry = entry,
onDone = function() inducted = true end,
})
game.stack:clear()
game.stack:push(induction)
local shots, phase = 0, nil
while not inducted and induction.frames < LIMIT do
U.wait(INDUCT_INTERVAL)
if induction.phase ~= phase then
phase = induction.phase
U.log(("hof phase %s at frame %d (mon %d, scx=%02x scy=%02x)")
:format(tostring(phase), induction.frames, induction.index,
induction.scx, induction.scy))
end
U.shot(game, ("%s/hof-%04d-%s.png")
:format(out, induction.frames, tostring(phase)))
shots = shots + 1
end
assert(inducted, "the induction never reached HOF_AnimatePlayerPic's end")
U.log(("%d induction shots over %d frames"):format(shots, induction.frames))
game.stack:pop()
-- ---- the credits --------------------------------------------------------
local rolled = false
local credits = Credits.new(game, {
allowSkip = wasEntered,
onDone = function() rolled = true end,
})
game.stack:clear()
game.stack:push(credits)
shots = 0
local scene = -1
while not credits.exiting and credits.frames < LIMIT do
U.wait(CREDITS_INTERVAL)
if credits.scene ~= scene then
scene = credits.scene
U.log(("credits scene %d at frame %d (pass %d, pos %d)")
:format(scene, credits.frames, credits.passes, credits.pos))
end
U.shot(game, ("%s/credits-%04d-scene%d.png")
:format(out, credits.frames, scene))
shots = shots + 1
end
assert(credits.exiting, "the credits script never reached CREDITS_END")
-- CREDITS_END only sets the exit flag; the screen waits on A, so the last
-- shot is THE END sitting there exactly as the player sees it.
U.wait(30)
U.shot(game, ("%s/credits-%04d-theend.png"):format(out, credits.frames))
U.log(("%d credits shots over %d frames, %d passes")
:format(shots + 1, credits.frames, credits.passes))
U.tap(game, "a")
U.wait(10)
assert(rolled, "A did not leave the credits once the exit flag was up")
game.stack:clear()
-- ---- the roster, as the PC shows it -------------------------------------
-- _HallOfFamePC over the row that was just written: A walks the team, and
-- the header is "-Time Famer" rather than "New Hall of Famer!".
local viewer = HallOfFame.new(game, {
mode = "view", save = save, onDone = function() end,
})
game.stack:push(viewer)
for index = 1, #entry.mons do
U.wait(10)
U.shot(game, ("%s/pc-%02d-%s.png")
:format(out, index, tostring((viewer:currentMon() or {}).species)))
U.tap(game, "a")
end
U.log("roster viewer shot for " .. #entry.mons .. " mon(s) in " .. out)
end
+152
View File
@@ -0,0 +1,152 @@
-- The Pokecenter heal machine and the POKeMART shelf, in the running game.
--
-- POKEPORT_IDENTITY=goldshopfix POKEPORT_GAME=gold POKEPORT_GOLD_HOUR=10 \
-- POKEPORT_DRIVER=tests/drivers/gold_heal_mart_shots.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Two things this is watching for:
-- * PokecenterNurseScript's `special HealMachineAnim` used to be a bare
-- (wrong) sfx: the balls, the flashing and MUSIC_HEAL all have to appear,
-- ON the machine at the counter's left end, and the nurse's next line
-- must wait for the last flash
-- * a mart clerk's shelf came from data/generated/marts.lua being absent,
-- so every shop opened empty: Cherrygrove must stock its four items with
-- ItemAttributes prices and a purchase must move money and the PACK
--
-- Shots land in /tmp/gold-heal-mart.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-heal-mart"
local fails = 0
local function ok(cond, msg)
if cond then print("[healmart] ok " .. msg)
else fails = fails + 1 print("[healmart] FAIL " .. msg) end
return cond
end
local function tap(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 4)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
-- A party worth three balls on the machine, all hurt so the heal is real.
local Mon = require("src.battle.gen2.Mon")
save.party = {}
for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "RATTATA" }) do
local mon = Mon.new(game.data, species, 10)
mon.hp = 1
save.party[#save.party + 1] = mon
end
save.player = save.player or {}
save.player.money = 5000
-- ------------------------------------------------------------- the nurse
w:setMap("CHERRYGROVE_POKECENTER_1F", 3, 3, "up")
U.wait(20)
U.shot(game, out .. "/00-pokecenter.png")
-- Talk across the counter, then hold A through the greeting and the
-- yes/no (YES is the default), stopping the moment the machine starts.
tap("a", 6)
local sawAnim, sawBalls, sawFlash, sawJingle = false, 0, false, false
local Music = require("src.core.Music")
for _ = 1, 60 * 30 do
local ha = w.healAnim
if ha then
sawAnim = true
if ha.lit > sawBalls then
sawBalls = ha.lit
U.shot(game, ("%s/01-ball-%d.png"):format(out, ha.lit))
end
if ha.phase == "flash" and not sawFlash and ha.rotation ~= 0 then
sawFlash = true
U.shot(game, out .. "/02-flash.png")
end
if Music.current() == "Music_HealPokemon" then sawJingle = true end
U.wait(1)
elseif sawAnim then
break
else
tap("a", 2)
end
end
ok(sawAnim, "the heal machine animation ran")
ok(sawBalls == 3, "one ball per party member landed (" .. sawBalls .. ")")
ok(sawFlash, "the machine flashed its palette")
ok(sawJingle, "MUSIC_HEAL played over the flashing")
U.shot(game, out .. "/03-after-flash.png")
-- The script is still mid-conversation ("thank you for waiting"); page out.
for _ = 1, 40 do
if not w:busy() then break end
tap("a", 4)
end
local healed = true
for _, mon in ipairs(save.party) do
if (mon.hp or 0) < (mon.maxHp or 1) then healed = false end
end
ok(healed, "the party left the counter at full HP")
U.shot(game, out .. "/04-healed.png")
-- -------------------------------------------------------------- the mart
w:setMap("CHERRYGROVE_MART", 2, 3, "left")
U.wait(20)
tap("a", 6)
-- Page the welcome line until the BUY/SELL/QUIT screen owns the stack.
local mart
for _ = 1, 120 do
local top = game.stack and game.stack:top()
if top and top.martType then mart = top break end
tap("a", 3)
end
if not ok(mart ~= nil, "the clerk opened the mart screen") then
U.shot(game, out .. "/05-no-mart.png")
print(("[healmart] %d failures"):format(fails))
love.event.quit(fails == 0 and 0 or 1)
return
end
U.shot(game, out .. "/05-mart-top.png")
ok(#mart.entries == 4, "Cherrygrove stocks four items ("
.. #mart.entries .. ")")
ok(mart.entries[1] and mart.entries[1].id == "POTION"
and mart.entries[1].price == 300,
"POTION at the ROM's own 300 leads the shelf")
tap("a", 6) -- BUY
U.shot(game, out .. "/06-buy-list.png")
tap("a", 6) -- pick POTION -> quantity
tap("up", 4) -- x2
U.shot(game, out .. "/07-quantity.png")
tap("a", 6) -- how many -> confirm
U.shot(game, out .. "/08-confirm.png")
tap("a", 8) -- YES
tap("a", 8) -- "Here you are" page
U.shot(game, out .. "/09-bought.png")
ok((save.inventory and save.inventory.POTION) == 2,
"two POTIONs landed in the PACK")
ok(save.player.money == 5000 - 600,
"the till took 600 (money " .. tostring(save.player.money) .. ")")
-- Leave: B out of the list, then QUIT + the come-again line.
tap("b", 6)
tap("b", 6)
tap("a", 6)
tap("a", 10)
U.shot(game, out .. "/10-outside.png")
print(("[healmart] %d failures"):format(fails))
love.event.quit(fails == 0 and 0 or 1)
end
+84
View File
@@ -0,0 +1,84 @@
-- HM07 ball probe: resume section 13, enter Ice Path 1F, run the exact
-- approach the route row 13.10 makes (approachAndFace the ball at (31,7)),
-- and report every state change on the way.
--
-- POKEPORT_IDENTITY=gold-v2b POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=13 \
-- POKEPORT_DRIVER=tests/drivers/gold_hm07_probe.lua love .
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local resume = os.getenv("POKEPORT_GOLD_RESUME")
if resume then
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[hm07] cannot resume %s: %s"):format(resume, tostring(err)))
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
end
local world = game.world
world:setMap("ICE_PATH_1F", 4, 19, "up")
bot:wait(30)
bot:clearDialogue(nil, 4000)
local function report(tag)
local px, py = A.pos(game)
print(("[hm07] %-10s map=%s pos=%s,%s ball=%s flag=%s busy=%s")
:format(tag, tostring(A.mapId(game)), tostring(px), tostring(py),
tostring(A.npcAt(game, 31, 7) ~= nil),
tostring(world.events:get(1672)),
tostring(A.busyReason(game))))
end
report("arrived")
local reached = bot:approachAndFace(31, 7)
print("[hm07] approachAndFace(31,7):", tostring(reached))
report("approached")
if reached then
for attempt = 1, 4 do
bot:tap("a")
bot:wait(4)
for _ = 1, 30 do
if A.busy(game) then break end
bot:wait(1)
end
if A.busy(game) then
print("[hm07] tap " .. attempt .. " opened something")
break
end
print("[hm07] tap " .. attempt .. " opened nothing")
end
bot:clearDialogue(nil, 4000)
end
report("done")
-- Engine introspection: is the press being lost, or is interact refusing?
local p = world.player
print(("[hm07] engine: busy=%s moving=%s facing=%s turnLatch=%s")
:format(tostring(world:busy()), tostring(p and p.moving),
tostring(p and p.facing), tostring(world.turningDirection)))
local npc = world:npcAt(31, 7)
print(("[hm07] engine npcAt(31,7): %s def.itemball=%s")
:format(tostring(npc ~= nil),
tostring(npc and npc.def and npc.def.itemball
and npc.def.itemball.item)))
local r = world:interact()
print("[hm07] direct world:interact():", tostring(r))
bot:wait(60)
bot:clearDialogue({ "yes" }, 3000)
report("direct")
love.event.quit()
end
+111
View File
@@ -0,0 +1,111 @@
-- Assertion driver: the champion's ending, end to end on the real game.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev POKEPORT_SPEED=200 \
-- POKEPORT_DRIVER=tests/drivers/gold_hof_continue.lua love .
--
-- The chain under test is the cart's own (engine/events/halloffame.asm,
-- engine/overworld/scripting.asm ReturnFromCredits, engine/menus/
-- intro_menu.asm Continue / FinishContinueFunction):
--
-- halloffame -> induction ceremony -> credits roll -> `jp Reset` (title)
-- CONTINUE -> wSpawnAfterChampion = SPAWN_LANCE consumed -> New Bark Town
--
-- tests/gen2_hof_continue_test.lua proves each link against registry fakes;
-- this runs the real screens on the real stack, lets the real induction write
-- the real save slot, and then CONTINUEs through Game2:continueGame exactly
-- as the main menu does. The active save slot is backed up first and
-- restored on the way out, whatever happens.
local U = require("tests.drivers.util")
local Gen2Save = require("src.core.gen2.Save")
local Mon = require("src.battle.gen2.Mon")
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- Guard the slot: the induction's SaveGameData writes it for real.
local main, bak, tmp = Gen2Save.filenames("gold")
local keep = {}
for _, name in ipairs({ main, bak, tmp }) do
keep[name] = love.filesystem.read(name)
end
local function restoreSlot()
for _, name in ipairs({ main, bak, tmp }) do
if keep[name] then
love.filesystem.write(name, keep[name])
else
love.filesystem.remove(name)
end
end
end
local ok, err = pcall(function()
local world, save, data = game.world, game.save, game.data
save.party = { Mon.new(data, "TYPHLOSION", 50) }
assert(save.party[1], "no party could be built from this cache")
save.player.name = save.player.name or "GOLD"
-- Stand where Script_halloffame runs: the Hall of Fame chamber.
assert(world:setMap("HALL_OF_FAME", 4, 12, "up"), "setMap HALL_OF_FAME")
U.wait(5)
-- The `halloffame` command, off the live world.
local resumed = false
assert(world:hallOfFame(function() resumed = true end),
"halloffame did not take the screen")
assert(save.spawnAfterChampion == "SPAWN_LANCE",
"induction did not write wSpawnAfterChampion")
-- The ceremony auto-advances; the roll follows it on the same call. A
-- first-time champion cannot skip, so ride it out and press A at THE END.
local sawCredits = false
for _ = 1, 700 do
local top = game.stack:top()
if top and top.screenId == "Gen2Credits" then
sawCredits = true
if top.exiting then break end
end
U.wait(30)
end
assert(sawCredits, "the credits never followed the induction")
local top = game.stack:top()
assert(top and top.exiting, "the credits never reached CREDITS_END")
U.tap(game, "a")
U.wait(10)
-- `jp Reset`: back on the title, world torn down, script resumed first.
assert(resumed, "the script never resumed out of the credits")
assert(game.phase == "boot", "the credits did not end on the title screen")
assert(game.world == nil, "the world survived the reset")
U.log("post-credits: reset to title, as FinishContinueFunction does")
-- The slot on disk carries the one-shot and the sealed room.
local written = Gen2Save.load("gold")
assert(written, "the induction never saved")
assert(written.spawnAfterChampion == "SPAWN_LANCE",
"the saved slot lost wSpawnAfterChampion")
assert(written.position and written.position.map == "HALL_OF_FAME",
"the saved position is not the Hall of Fame")
U.log("slot: spawnAfterChampion=SPAWN_LANCE, position=HALL_OF_FAME")
-- CONTINUE, exactly as the main menu's row does it.
game:continueGame(written)
U.wait(10)
assert(game.world and game.world.map, "CONTINUE did not boot a world")
assert(game.world.map.id == "NEW_BARK_TOWN",
"CONTINUE resumed on " .. tostring(game.world.map.id)
.. ", expected NEW_BARK_TOWN")
assert(game.world.player.cellX == 13 and game.world.player.cellY == 6,
("CONTINUE landed at (%d,%d), expected SPAWN_NEW_BARK (13,6)")
:format(game.world.player.cellX, game.world.player.cellY))
assert(game.save.spawnAfterChampion == nil,
"PostCreditsSpawn did not zero the byte")
U.log("CONTINUE: spawned at New Bark Town, byte consumed")
end)
restoreSlot()
assert(ok, err)
U.log("PASS gold_hof_continue")
love.event.quit()
end
+152
View File
@@ -0,0 +1,152 @@
-- The command queue, on the map that needs it.
--
-- `writecmdqueue` / `delcmdqueue` were no-ops because there was no wCmdQueue
-- engine (engine/overworld/cmd_queue.asm, polled once a frame). Ice Path B1F
-- is one of the two maps that use CMDQUEUE_STONETABLE, and it is the one that
-- matters: the queue is what makes a pushed boulder fall through the hole, Ice
-- Path gates Blackthorn, and Blackthorn is the eighth badge.
--
-- This pushes ICEPATHB1F_BOULDER1 north onto warp 3 at (11,2) with STRENGTH
-- active, and asserts the boulder falls and its twin one floor down appears.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_icepath_boulder.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-icepath"
return function(game)
local w = game.world
local fails = 0
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function ok(cond, msg)
if cond then print("[icepath] ok " .. msg)
else fails = fails + 1 print("[icepath] FAIL " .. msg) end
return cond
end
local function clearDirs()
game.input.pressQueue = {}
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
game.input.sources[d] = nil
end
end
local function hold(dir, frames)
clearDirs()
for _ = 1, frames do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
clearDirs()
coroutine.yield()
end
local function boulder()
for _, npc in ipairs(w.npcs) do
if npc.def and npc.def.index == 1 then return npc end
end
return nil
end
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
wait(45)
-- ICEPATHB1F_BOULDER1's hole is warp 3 at (11,2), and (11,4) is wall, so the
-- boulder reaches it from the WEST: the last push of the puzzle is the player
-- at (9,2) walking right into a boulder on (10,2).
--
-- The boulder starts the map at (11,7) and the route between is most of the
-- floor's maze. This driver is about the queue, not about the maze, so it
-- parks the boulder on the cell the maze delivers it to and performs the LAST
-- push for real. Nothing else is faked: the push is a walk, the fall is the
-- queue noticing, and the script is the map's own.
w:setMap("ICE_PATH_B1F", 9, 2, "right")
wait(20)
ok(w.map.id == "ICE_PATH_B1F", "on Ice Path B1F")
local CmdQueue = require("src.world.gen2.CmdQueue")
ok(CmdQueue.count(w.cmdQueue) == 1,
"the map load wrote its MAPCALLBACK_CMDQUEUE entry ("
.. CmdQueue.count(w.cmdQueue) .. " slot(s) used)")
local b = boulder()
ok(b ~= nil, "boulder 1 is on the map")
ok(b and b.cellX == 11 and b.cellY == 7,
("at its spawn (11,7), got (%s,%s)"):format(tostring(b and b.cellX),
tostring(b and b.cellY)))
ok(w.map:cellCollision(11, 2) == 0x60,
"and the tile at its hole is COLL_PIT")
-- One cell west of the hole, which is where the maze push route ends.
b.cellX, b.cellY = 10, 2
b.homeX, b.homeY = 10, 2
b.px, b.py = 10 * 16, 2 * 16
ok(w.map:isWalkable(10, 2), "the cell it is pushed from is floor")
-- BIKEFLAGS_STRENGTH_ACTIVE, which .CheckStrengthBoulder reads. Getting it
-- the honest way needs a party with STRENGTH and the RISING BADGE; this
-- driver is about the queue, not about the field move.
w.strengthActive = true
game.capturePath = SHOT_DIR .. "/before.png"
wait(2)
-- The push: walking into an occupied cell with STRENGTH active is what
-- .CheckStrengthBoulder turns into a step for the boulder instead.
for i = 1, 3 do
hold("right", 40)
local bb = boulder()
print(("[icepath] push %d: player (%d,%d) boulder (%s,%s)"):format(
i, w.player.cellX, w.player.cellY,
tostring(bb and bb.cellX), tostring(bb and bb.cellY)))
if w:busy() or bb == nil then break end
end
-- The queue drops it on the first frame it is standing on the pit, and the
-- script that runs is pause 30 / SFX_STRENGTH / earthquake 80 / the line.
local fell = false
for _ = 1, 400 do
if w:busy() then fell = true break end
wait(2)
end
ok(fell, "something started once the boulder reached the hole")
game.capturePath = SHOT_DIR .. "/falling.png"
wait(4)
for _ = 1, 400 do
if not w:busy() then break end
table.insert(game.input.pressQueue, "a")
wait(3)
end
wait(20)
ok(boulder() == nil, "the boulder is gone from B1F")
-- EVENT_BOULDER_IN_ICE_PATH_1A (1805) hides the twin on the floor below; the
-- script CLEARS it, which is what puts the fallen boulder down there.
ok(w.events:get(1805) == false,
"and EVENT_BOULDER_IN_ICE_PATH_1A is clear, so it is on B2F now")
game.capturePath = SHOT_DIR .. "/after.png"
wait(4)
-- Follow it down and look.
w:setMap("ICE_PATH_B2F_MAHOGANY_SIDE", 11, 5, "up")
wait(30)
local below = nil
for _, npc in ipairs(w.npcs) do
if npc.def and npc.def.index == 1 then below = npc end
end
ok(below ~= nil and below.cellX == 11 and below.cellY == 3,
"the boulder is standing on B2F at (11,3)")
game.capturePath = SHOT_DIR .. "/below.png"
wait(30)
if fails > 0 then
error(("gold ice path: %d assertion(s) failed"):format(fails))
end
print("[driver] PASS gold command queue: the boulder fell through")
end
+55
View File
@@ -0,0 +1,55 @@
-- The Gold/Silver intro movie, sampled every half second.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_intro_shots.lua love .
--
-- A cinematic is the one thing no assertion can check: "does Lapras surface
-- before the fade", "is the water bending", "is the fireball spiralling" are
-- questions for eyes. So this pushes the real GoldSilverIntro onto the stack,
-- lets it run at its own 60 Hz, and lays the whole ~39 seconds out as a
-- contact sheet in POKEPORT_SHOT_DIR (/tmp/gold-intro by default).
--
-- Shots are named by the movie's own frame counter and current scene, so a
-- file is directly comparable against engine/movie/intro.asm's jumptable.
local U = require("tests.drivers.util")
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
-- Every 30 frames covers each act's beats without producing an unreadable
-- number of files; the movie runs about 2340 frames end to end.
local INTERVAL = 30
local LIMIT = 3000
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-intro"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or INTERVAL
U.wait(30)
local assets = game.data and game.data.gen2Intro
if not (assets and assets.water and assets.water.meta) then
print("[driver] SKIP no intro tables in this cache -- re-import Gold")
return
end
local finished = false
local intro = GoldSilverIntro.new(game, {
onDone = function() finished = true end,
})
game.stack:clear()
game.stack:push(intro)
local shots, scene = 0, 0
while not finished and intro.frames < LIMIT do
U.wait(interval)
if intro.scene ~= scene then
scene = intro.scene
print(("[driver] scene %d at frame %d (scx=%02x scy=%02x objs=%d)")
:format(scene, intro.frames, intro.scx, intro.scy,
intro.anims:activeCount()))
end
U.shot(game, ("%s/%04d-scene%02d.png"):format(out, intro.frames, scene))
shots = shots + 1
end
assert(finished, "the movie never reached the end of IntroScene17")
print(("[driver] %d shots in %s over %d frames")
:format(shots, out, intro.frames))
end
+109
View File
@@ -0,0 +1,109 @@
-- The seam gold_giveitem_box.lua measures, on the OTHER two scripts that hand
-- the player an item in the overworld.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_item_pickup_box.lua love .
--
-- FindItemInBallScript (engine/events/misc_scripts.asm:10-19) is
-- `opentext / writetext .FoundItemText / playsound SFX_ITEM / pause 60 /
-- itemnotify / closetext`, and FruitTreeScript (engine/events/fruit_trees.asm
-- :17-25) is `writetext ObtainedFruitText / callasm PickedFruitTree /
-- specialsound / itemnotify`. Neither has a `waitbutton` between the found
-- line and the itemnotify line: both print into the ONE MapTextbox the script's
-- own `opentext` opened, and nothing takes it down in between.
--
-- So the same rule as GiveItemScript applies: no frame between the two pages
-- may render with an empty state stack, because that is a visible tear-down of
-- the box AND it lets Game2's play clock (wGameTimerPaused, which is only held
-- while a state is on the stack) come off pause mid-pickup.
local U = require("tests.drivers.util")
local HiddenItems = require("src.world.gen2.HiddenItems")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-item-pickup"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local function clockFrames()
local t = game.save.playTime
return ((t.hours * 60 + t.minutes) * 60 + t.seconds) * 60 + t.frames
end
-- Same sampling shape as gold_giveitem_box: every frame is looked at, and
-- the A press goes in every sixth, so the seam between two pages is never
-- stepped over by a sample that only lands on press frames.
local function measure(label, script, shotPrefix)
game.save.playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 }
world.vm:start(script)
local bare, boxes, seenFirst = 0, 0, false
local clockFirst, clockLast
local shots, pressIn = 0, 8
for _ = 1, 1200 do
if not world:busy() and seenFirst then break end
local top = game.stack:top()
if top then
if not seenFirst then
seenFirst = true
clockFirst = clockFrames()
end
clockLast = clockFrames()
if shots < 2 and boxes % 12 == 6 then
shots = shots + 1
U.shot(game, ("%s/%s-%02d-page.png"):format(out, shotPrefix, shots))
end
boxes = boxes + 1
elseif seenFirst then
bare = bare + 1
end
pressIn = pressIn - 1
if pressIn <= 0 then
pressIn = 6
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(1)
game.input.state.a = false
else
U.wait(1)
end
end
local spent = (clockLast or 0) - (clockFirst or 0)
print(("[driver] %s: %d box frames, %d bare frames, clock ran %d frames")
:format(label, boxes, bare, spent))
assert(seenFirst, label .. ": no text box ever went up")
return bare, spent
end
-- FindItemInBallScript, exactly as World:interact builds it for every Poke
-- Ball on the floor. Object 1 stands in for LAST_TALKED; `disappear` on an
-- object this map may not have is a no-op, which is fine here -- the seam
-- being measured is the text, not the despawn.
local ballBare, ballClock = measure("item ball",
HiddenItems.ballPickupScript("POTION", 1, 1,
function(want, id) return world:sfxIdNamed(want, id) end),
"ball")
U.wait(20)
-- FruitTreeScript. Tree 1 is FRUITTREE_ROUTE_29 (the BERRY on Route 29).
-- The script's own `callasm TryResetFruitTrees` clears wFruitTreeFlags on
-- the first examine after the daily rollover, so a fresh boot takes the arm
-- that actually hands the fruit over rather than "There's nothing here".
local treeBare, treeClock = measure("fruit tree",
{ { op = "opentext" }, { op = "fruittree", args = { 1 } } },
"tree")
assert(ballBare == 0,
("the overworld drew bare for %d frames inside ONE FindItemInBallScript "
.. "textbox"):format(ballBare))
assert(ballClock == 0,
("the play clock ran %d frames while an item ball was picked up")
:format(ballClock))
assert(treeBare == 0,
("the overworld drew bare for %d frames inside ONE FruitTreeScript "
.. "textbox"):format(treeBare))
assert(treeClock == 0,
("the play clock ran %d frames while a berry was picked"):format(treeClock))
print("[driver] PASS gold item pickup single box in " .. out)
love.event.quit()
end
+97
View File
@@ -0,0 +1,97 @@
-- Lake of Rage chain probe: resume section 10, travel, fight the Red Gyarados,
-- talk to Lance, enter the mart, and print the three Cluster C flags.
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
local function flag(game, name)
local v = A.event(game, name)
return v == nil and "?" or (v and "SET" or "clear")
end
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end
local ok, err = A.loadCheckpoint(game, "10")
if not ok then
print("[lake] resume fail " .. tostring(err))
return
end
for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end
bot:forgetSurf()
print(("[lake] start %s surf=%s gyarados=%s lance=%s stairs=%s")
:format(tostring(A.mapId(game)), tostring(bot:canSurf()),
flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS"),
flag(game, "EVENT_DECIDED_TO_HELP_LANCE"),
flag(game, "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART")))
-- Burn a visit to the lake and back so edgeTries matches a real section-10
-- approach (10.26 then 10.g), which is what exposed the skip=1 pocket bug.
print("[lake] priming edgeTries via lake <-> route 43...")
bot:travelTo("LAKE_OF_RAGE")
bot:travelTo("ROUTE_43")
print("[lake] travelTo LAKE_OF_RAGE for the fight...")
if not bot:travelTo("LAKE_OF_RAGE") then
print("[lake] FAIL travel")
return
end
local nx, ny = A.pos(game)
print(("[lake] arrived %s @%d,%d regionSize=%d")
:format(tostring(A.mapId(game)), nx or -1, ny or -1, bot:regionSize()))
print("[lake] approach+A Red Gyarados at 18,22...")
if not bot:approachAndFace(18, 22) then
print("[lake] FAIL approach gyarados")
return
end
local ax, ay = A.pos(game)
print(("[lake] standing @%d,%d facing=%s -- pressing A")
:format(ax or -1, ay or -1, tostring(A.facing(game))))
bot:tap("a")
bot:wait(8)
bot:clearDialogue(nil, 12000)
print(("[lake] after gyarados: gyarados=%s lanceObj=%s")
:format(flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS"),
flag(game, "EVENT_LAKE_OF_RAGE_LANCE")))
if flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS") ~= "SET" then
print("[lake] FAIL: EVENT_LAKE_OF_RAGE_RED_GYARADOS still clear")
return
end
print("[lake] talk Lance at 21,28...")
if not bot:approachAndFace(21, 28) then
print("[lake] FAIL approach lance")
return
end
bot:tap("a")
bot:wait(8)
bot:clearDialogue({ "yes" }, 8000)
print(("[lake] after lance: decided=%s")
:format(flag(game, "EVENT_DECIDED_TO_HELP_LANCE")))
if flag(game, "EVENT_DECIDED_TO_HELP_LANCE") ~= "SET" then
print("[lake] FAIL: EVENT_DECIDED_TO_HELP_LANCE still clear")
return
end
print("[lake] travelTo MAHOGANY_MART_1F...")
if not bot:travelTo("MAHOGANY_MART_1F") then
print("[lake] FAIL travel mart")
return
end
-- Scene script runs on entry.
bot:clearDialogue(nil, 8000)
print(("[lake] after mart: stairs=%s map=%s")
:format(flag(game, "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART"),
tostring(A.mapId(game))))
if flag(game, "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART") == "SET"
and flag(game, "EVENT_DECIDED_TO_HELP_LANCE") == "SET"
and flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS") == "SET" then
print("[lake] OK: Cluster C chain complete")
else
print("[lake] FAIL: chain incomplete")
end
end
+261
View File
@@ -0,0 +1,261 @@
-- The Gen 2 link surface, exercised against a REAL Gold boot.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_link_fingerprint.lua love .
--
-- Gold cannot link (docs/gen2-link-design.md is the honest account of what that
-- would take), so this is not a link smoke test. It is the check that the
-- pieces which ARE built work against the extracted Gold dataset rather than
-- against a fixture:
--
-- 1. the dataset identifies itself as generation 2 with no help from
-- GameVersion, and Handshake.hello says so on the wire
-- 2. the Gen 2 fingerprint is stable, moves when a surface field moves, and
-- does NOT move when a non-surface field moves (the #511 lesson, checked
-- on Gold's own tables this time)
-- 3. a Gold peer and a Red peer refuse each other by generation instead of
-- pairing and desyncing
-- 4. every mon in a real Gold party survives packMon2 -> unpackMon2 with its
-- stats, experience, held item, happiness and derived shininess intact
--
-- A fixture test cannot say any of that, because the whole question is whether
-- the extracted tables and the engine agree.
local U = require("tests.drivers.util")
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local Mon = require("src.battle.gen2.Mon")
local Protocol = require("src.link.Protocol")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-link"
local failures = 0
local function check(ok, label)
if ok then
U.log("ok ", label)
else
failures = failures + 1
U.log("FAIL", label)
end
return ok
end
U.wait(45)
local data = game.data
assert(data and data.pokemon and next(data.pokemon), "gold data did not load")
-- ---- 1. generation, off the data alone
check(Fingerprint.generationOf(data) == 2,
"the Gold dataset reports itself as generation 2")
local hello = Handshake.hello(game, "trade")
check(hello.generation == 2, "the hello carries generation 2")
check(type(hello.fingerprint) == "string" and #hello.fingerprint == 16,
"the hello carries a 16-hex-digit Gen 2 fingerprint")
U.log(("fingerprint %s protocol %d engine %s"):format(
tostring(hello.fingerprint), hello.protocol, tostring(hello.engineVersion)))
-- ---- 2. stability and coverage
-- The baseline is recomputed with an EMPTY mod list, not taken from the
-- hello. Handshake.hello folds Handshake.mods(game) into the digest, and
-- every comparison below computes with {}, so on an install with one enabled
-- link-affecting mod the two differ by modKey alone -- which reads as "the
-- digest is not stable" and "catchRate moved the digest" for a reason having
-- nothing to do with the surface. The hello's own digest is asserted above;
-- from here on the baseline and the comparisons share a mod list.
Fingerprint.forget(data)
local base = Fingerprint.compute(data, {})
Fingerprint.forget(data)
check(Fingerprint.compute(data, {}) == base,
"the digest is stable across a forget/recompute")
-- The Gen 1 surface over the SAME tables must not collide with the Gen 2
-- one: checkCompat refuses a cross-generation pairing by the hello, and the
-- "[gen2]" tag is what makes the digest agree with that refusal.
Fingerprint.forget(data)
local asGen1 = Fingerprint.compute(data, {}, 1)
Fingerprint.forget(data)
check(asGen1 ~= base, "the Gen 1 and Gen 2 surfaces digest differently")
local function digestAfter(mutate, restore)
mutate()
Fingerprint.forget(data)
local value = Fingerprint.compute(data, {})
restore()
Fingerprint.forget(data)
return value
end
-- surface: a base stat, a move's power, a move's effect chance, a held
-- item's parameter, a growth curve coefficient. Each one changes a battle
-- turn or a trade rebuild, so each one must move the digest.
local species = data.pokemon.TOTODILE or data.pokemon.CYNDAQUIL
local before = species.baseStats.attack
check(digestAfter(function() species.baseStats.attack = before + 1 end,
function() species.baseStats.attack = before end) ~= base,
"a Gen 2 base stat moves the digest")
local move = data.moves.TACKLE
local movePower = move.power
check(digestAfter(function() move.power = movePower + 1 end,
function() move.power = movePower end) ~= base,
"a move's power moves the digest")
local chanceMove, chanceBefore
for _, id in ipairs({ "BODY_SLAM", "THUNDERBOLT", "ICE_BEAM" }) do
if data.moves[id] and data.moves[id].effectChance then
chanceMove, chanceBefore = data.moves[id], data.moves[id].effectChance
break
end
end
if chanceMove then
check(digestAfter(function() chanceMove.effectChance = chanceBefore + 1 end,
function() chanceMove.effectChance = chanceBefore end) ~= base,
"a move's effectChance moves the digest (Gen 2 only)")
else
U.log("skip no move with an effectChance in this dataset")
end
local held = data.gen2HeldItems and data.gen2HeldItems.LEFTOVERS
if held then
local heldBefore = held.heldParameter
check(digestAfter(function() held.heldParameter = (heldBefore or 0) + 1 end,
function() held.heldParameter = heldBefore end) ~= base,
"a held item's parameter moves the digest")
else
U.log("skip no LEFTOVERS held-item row in this dataset")
end
local curves = data.pokemon.growthRates
local curve = curves and (curves.GROWTH_MEDIUM_SLOW or select(2, next(curves)))
if curve then
local linearBefore = curve.linear
check(digestAfter(function() curve.linear = (linearBefore or 0) + 1 end,
function() curve.linear = linearBefore end) ~= base,
"a growth-curve coefficient moves the digest")
else
U.log("skip no growth-rate coefficient rows in this dataset")
end
-- NOT surface: catchRate (#511) and the constants index space. Either one
-- moving the digest would split two peers over something neither of their
-- simulations reads.
local catchBefore = species.catchRate
check(digestAfter(function() species.catchRate = (catchBefore or 0) + 1 end,
function() species.catchRate = catchBefore end) == base,
"catchRate does NOT move the digest")
if data.gen2Constants and data.gen2Constants.mapOrder then
local order = data.gen2Constants.mapOrder
local first = order[1]
check(digestAfter(function() order[1] = "NOT_A_MAP" end,
function() order[1] = first end) == base,
"the constants index space does NOT move the digest")
end
-- the per-record digests a subset trade negotiates on
local speciesRecords = Fingerprint.records(data, "pokemon")
local heldRecords = Fingerprint.records(data, "held_items")
check(speciesRecords.TOTODILE ~= nil and speciesRecords.growthRates == nil,
"per-species digests cover the species and not the growthRates sibling")
check(next(heldRecords) ~= nil, "per-held-item digests exist on Gold")
-- ---- 3. a Gold peer refuses a Red peer
local redHello = { type = "hello", protocol = hello.protocol,
name = "RED", generation = 1,
engineVersion = hello.engineVersion,
fingerprint = "0000000000000000", mods = {} }
local verdict, reason = Handshake.checkCompat(hello, redHello)
check(verdict == "refused" and reason == "generation_mismatch",
"a Gold hello refuses a Gen 1 peer by generation")
local oldHello = { type = "hello", name = "OLD" } -- pre-handshake build
check(Handshake.checkCompat(hello, oldHello) == "refused",
"a Gold hello refuses a pre-handshake build")
local lines = Handshake.describe(hello, redHello, "refused", "trade")
check(#lines > 0 and table.concat(lines, " "):find("generation"),
"the incompatibility screen names the generation")
for _, line in ipairs(lines) do U.log(" screen |" .. line) end
-- ---- 4. a real Gold party through the Gen 2 codec
local party = {}
for _, spec in ipairs({ { "TOTODILE", 12 }, { "PIDGEY", 7 },
{ "GEODUDE", 15 } }) do
local mon = Mon.new(data, spec[1], spec[2])
if mon then party[#party + 1] = mon end
end
check(#party == 3, "built a three-mon Gold party from the extracted tables")
-- a held item and a status, so the two fields the Gen 1 codec cannot carry
-- are actually under test
party[1].item = (data.items and data.items.LEFTOVERS) and "LEFTOVERS" or nil
party[1].status = "burn"
party[1].happiness = 137
party[1].pokerus = 0
party[1].ot, party[1].otId = "KRIS", 41234
party[2].hp = math.max(1, math.floor(party[2].maxHp / 2))
for i, mon in ipairs(party) do
local packed = Protocol.packMon2(mon)
local rebuilt, why = Protocol.unpackMon2(data, packed, { strict = true })
if not check(rebuilt ~= nil, ("slot %d rebuilds (%s)"):format(
i, tostring(why))) then break end
check(rebuilt.species == mon.species and rebuilt.level == mon.level,
("slot %d keeps species and level"):format(i))
check(rebuilt.experience == mon.experience,
("slot %d keeps experience (%s vs %s)"):format(
i, tostring(rebuilt.experience), tostring(mon.experience)))
check(rebuilt.hp == mon.hp and rebuilt.maxHp == mon.maxHp,
("slot %d keeps HP %s/%s"):format(i, tostring(rebuilt.hp),
tostring(rebuilt.maxHp)))
local same = true
for _, k in ipairs({ "hp", "attack", "defense", "speed",
"specialAttack", "specialDefense" }) do
if rebuilt.stats[k] ~= mon.stats[k] then same = false end
end
check(same, ("slot %d recomputes all six stats identically"):format(i))
check(rebuilt.shiny == mon.shiny and rebuilt.gender == mon.gender,
("slot %d re-derives shininess and gender from the DVs"):format(i))
check(#rebuilt.moves == #mon.moves, ("slot %d keeps its moveset"):format(i))
check(rebuilt.item == mon.item, ("slot %d keeps its held item (%s)"):format(
i, tostring(rebuilt.item)))
check(rebuilt.status == mon.status, ("slot %d keeps its status"):format(i))
check(rebuilt.happiness == mon.happiness,
("slot %d keeps its happiness"):format(i))
check(rebuilt.otId == mon.otId and rebuilt.ot == mon.ot,
("slot %d keeps its original trainer"):format(i))
end
-- the HP DV is derived, never sent: a packet that claims one is ignored
local tampered = Protocol.packMon2(party[1])
tampered.dvs.hp = 15
local rebuilt = Protocol.unpackMon2(data, tampered, { strict = true })
check(rebuilt and rebuilt.dvs.hp == Mon.hpDV(party[1].dvs),
"a claimed HP DV is ignored and re-derived from the other four")
-- an item the peer's game does not have is refused rather than carried
local noSuchItem = Protocol.packMon2(party[1])
noSuchItem.item = "MOON_FLUTE"
local _, itemWhy = Protocol.unpackMon2(data, noSuchItem, { strict = true })
check(itemWhy == "unknown item",
"an unknown held item is refused in strict mode")
-- and the subset filter says so BEFORE the mon is ever sent
local mine = { pokemon = Fingerprint.records(data, "pokemon"),
moves = Fingerprint.records(data, "moves"),
heldItems = Fingerprint.records(data, "held_items") }
local theirs = { pokemon = mine.pokemon, moves = mine.moves, heldItems = {} }
local eligible, reasons = Protocol.eligibleParty(party, mine, theirs)
check(eligible[1] == false and reasons[1] == "unknown item",
"the subset filter greys a mon whose held item the peer lacks")
check(eligible[2] == true, "a mon holding nothing stays tradeable")
-- ---- proof the game was actually up while all of that ran
U.shot(game, out .. "/01-gold-link-fingerprint.png")
U.log(("%d failures"):format(failures))
assert(failures == 0, ("gold link fingerprint driver: %d failures"):format(
failures))
U.log("PASS")
end
+106
View File
@@ -0,0 +1,106 @@
-- MahoganyMart1FLanceUncoversStaircaseScript (maps/MahoganyMart1F.asm:63), the
-- scene where Lance's DRAGONITE hyper-beams the Rocket behind the counter.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_mahogany_lance.lua love .
--
-- MAHOGANYMART1F_LANCE and MAHOGANYMART1F_DRAGONITE share ONE
-- MAPOBJECT_EVENT_FLAG (maps/MahoganyMart1F.asm:158-159), and the script
-- `disappear`s the DRAGONITE less than half way through and Lance only at the
-- very end -- so a port that derives who is standing from the event flag pulls
-- Lance off the map the moment his Dragonite goes, and the rest of his walk and
-- all three of his text boxes then come out of nobody.
--
-- The run prints the standing census every beat and shoots the two moments a
-- human has to look at: Lance mid-speech (he must be ON SCREEN) and the room
-- after he takes the stairs (he must be GONE, and an A press where he stood
-- must do nothing).
local U = require("tests.drivers.util")
local LANCE, DRAGONITE = 3, 4 -- def.objects indices; object consts are +1
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-mahogany"
local function tap(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 4)
end
local world
local function standing(index)
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.index == index then return npc end
end
return nil
end
U.wait(45)
world = game.world
assert(world and world.map, "gold world did not boot")
-- LakeOfRage.asm:61 `clearevent EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` is
-- what puts the pair in the shop; both objects carry that ONE flag
-- (maps/MahoganyMart1F.asm:236-237). Read it off the object rather than
-- naming a number, so a re-extracted cache cannot make this stale.
world:setMap("MAHOGANY_MART_1F", 3, 6, "up")
U.wait(5)
world.events:set(world.map.def.objects[LANCE].eventFlag, false)
-- SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS is scene 1; the scene script is
-- `sdefer`, and World:step only arms it on a map ENTRY, so the id has to be
-- in place before the load that runs it.
world.mapScenes["MAHOGANY_MART_1F"] = 1
world:setMap("MAHOGANY_MART_1F", 3, 6, "up")
U.wait(20)
assert(world.map.id == "MAHOGANY_MART_1F", tostring(world.map.id))
assert(standing(LANCE), "Lance is not on the map before the scene")
assert(standing(DRAGONITE), "the Dragonite is not on the map before the scene")
U.shot(game, out .. "/00-before.png")
local lanceGoneAt, dragoniteGoneAt, shotMidway = nil, nil, false
for step = 1, 900 do
if not world:busy() and step > 30 then break end
local lance, drag = standing(LANCE), standing(DRAGONITE)
if not drag and not dragoniteGoneAt then dragoniteGoneAt = step end
if not lance and not lanceGoneAt then lanceGoneAt = step end
-- The Dragonite is gone and Lance is still talking: this is the frame the
-- shared flag would have culled him on.
if dragoniteGoneAt and not shotMidway and step == dragoniteGoneAt + 30 then
shotMidway = true
U.shot(game, out .. "/01-after-hyper-beam.png")
print(("[driver] after the Dragonite went: Lance standing = %s")
:format(tostring(standing(LANCE) ~= nil)))
end
tap("a", 2)
end
print(("[driver] Dragonite left the map at beat %s, Lance at beat %s")
:format(tostring(dragoniteGoneAt), tostring(lanceGoneAt)))
U.wait(20)
U.shot(game, out .. "/02-after.png")
assert(dragoniteGoneAt, "the Dragonite never disappeared")
assert(lanceGoneAt, "Lance never disappeared")
assert(lanceGoneAt > dragoniteGoneAt + 20,
("Lance left the map %d beats after his Dragonite; the script keeps him "
.. "standing for the whole walk, the radio speech, the stairs and the "
.. "split-up line"):format(lanceGoneAt - dragoniteGoneAt))
-- The other half: a masked object must not answer an A press. Stand where
-- Lance ended up and face him.
local def = world.map.def.objects[LANCE]
world.player.cellX, world.player.cellY = def.x, def.y + 1
world.player.px = world.player.cellX * 16
world.player.py = world.player.cellY * 16
world.player.facing = "up"
U.wait(4)
local answered = world:interact()
print("[driver] A press on the masked Lance answered: " .. tostring(answered))
assert(not answered, "a masked object answered a talk")
print("[driver] PASS gold mahogany Lance scene in " .. out)
love.event.quit()
end
+199
View File
@@ -0,0 +1,199 @@
-- Assertion driver: the four map callback types, run by a real map load in the
-- running game. It PASSES or it errors; there is nothing to eyeball.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_map_callbacks.lua love .
--
-- tests/gen2_map_callbacks_test.lua checks the bodies and the order against
-- fixtures; what it cannot check is that a genuine World:setMap, with a genuine
-- cache under it, comes out the other side with the door shut and the right
-- day's NPC standing there. So each check here sets the state the cart's own
-- callback branches on, loads the map for real, and reads the world back.
--
-- Every number is read out of the extracted callback body rather than typed in,
-- so a re-import that renumbers an event or an object fails on the LOOKUP with
-- a name attached instead of quietly asserting the wrong thing.
local U = require("tests.drivers.util")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local function body(mapId, kind)
local def = world.maps[mapId]
assert(def, "no such map: " .. mapId)
for _, cb in ipairs(def.callbacks or {}) do
if cb.callback == kind then
local list = world.scripts[cb.scriptKey]
assert(list, ("%s %s names a body the cache does not carry (%s)")
:format(mapId, kind, tostring(cb.scriptKey)))
return list
end
end
error(("%s declares no %s -- re-import, or the extractor lost it")
:format(mapId, kind))
end
-- The first command of a kind in a body, so the driver can name a flag by the
-- command that reads it rather than by a constant it would have to keep in
-- sync with constants/event_flags.asm.
local function firstOp(list, op)
for _, cmd in ipairs(list) do
if cmd.op == op then return cmd end
end
return nil
end
local function load(mapId, x, y)
assert(world:setMap(mapId, x, y, "down"), "setMap failed for " .. mapId)
U.wait(2)
end
-- ---- MAPCALLBACK_NEWMAP ------------------------------------------------
-- GoldenrodUndergroundResetSwitchesCallback: fifteen clearevents and a
-- `writemem wUndergroundSwitchPositions`. The puzzle is only solvable
-- because walking in resets it, so a callback that does not run leaves the
-- doors wherever the last visit left them.
do
local list = body("GOLDENROD_UNDERGROUND", "MAPCALLBACK_NEWMAP")
local cleared = {}
for _, cmd in ipairs(list) do
if cmd.op == "clearevent" then cleared[#cleared + 1] = cmd.event end
end
assert(#cleared >= 15,
("expected the fifteen switch/door events, found %d"):format(#cleared))
for _, id in ipairs(cleared) do world.events:set(id, true) end
load("GOLDENROD_UNDERGROUND", 3, 3)
for _, id in ipairs(cleared) do
assert(not world.events:get(id),
("MAPCALLBACK_NEWMAP left event %d set: the switches did not reset")
:format(id))
end
U.log(("NEWMAP: the map load cleared all %d underground switch events")
:format(#cleared))
end
-- ---- MAPCALLBACK_TILES -------------------------------------------------
-- BrunosRoomDoorsCallback: `changeblock 4, 14, $2a` walls the entrance in
-- once EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED is set. Script_changeblock's two
-- bytes are CELL coordinates (`add 4` then GetBlockLocation's `srl`), so the
-- block it rewrites is (x / 2, y / 2).
do
local list = body("BRUNOS_ROOM", "MAPCALLBACK_TILES")
local seal = firstOp(list, "changeblock")
local gate = firstOp(list, "checkevent")
assert(seal and gate, "Bruno's TILES callback lost its changeblock")
local args = seal.args or {}
local bx = math.floor((seal.x or args[1]) / 2)
local by = math.floor((seal.y or args[2]) / 2)
local wall = seal.block or args[3]
local def = world.maps.BRUNOS_ROOM
local index = by * def.width + bx + 1
world.events:set(gate.event, false)
load("BRUNOS_ROOM", 4, 12)
local open = def.blocks[index]
assert(open ~= wall,
"the entrance is already walled in with the event clear")
world.events:set(gate.event, true)
load("BRUNOS_ROOM", 4, 12)
assert(def.blocks[index] == wall,
("MAPCALLBACK_TILES did not seal Bruno's door: block %d is %s, want %s")
:format(index, tostring(def.blocks[index]), tostring(wall)))
-- And it comes back. restoreBlocks is LoadMapAttributes' refill; without
-- the bake being dropped with it, the wall would have been painted into the
-- cached canvas for the rest of the session.
world.events:set(gate.event, false)
load("BRUNOS_ROOM", 4, 12)
assert(def.blocks[index] == open,
"the wall did not come back out of the buffer when the event cleared")
U.log(("TILES: Bruno's entrance block %d flips %s <-> %s with the event")
:format(index, tostring(open), tostring(wall)))
end
-- ---- MAPCALLBACK_OBJECTS -----------------------------------------------
-- Route29TuscanyCallback: ZEPHYRBADGE, then `readvar VAR_WEEKDAY` and
-- `ifnotequal TUESDAY`. One of the seven travelling siblings, and the
-- clearest thing on the list that a player can walk up to and talk to.
do
local list = body("ROUTE_29", "MAPCALLBACK_OBJECTS")
local badge = firstOp(list, "checkflag")
assert(badge, "Route 29's OBJECTS callback lost its badge check")
-- The `appear` sits in .DoesTuscanyAppear, behind the `iftrue`; what is in
-- the body itself is the .TuscanyDisappears fallthrough, and it names the
-- same object.
local hide = firstOp(list, "disappear")
assert(hide, "and its disappear")
local objectId = hide.object or (hide.args and hide.args[1])
assert(objectId, "the disappear names no object")
local index = objectId - 1
local function tuscanyOut()
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.index == index then return true end
end
return false
end
world:setEngineFlag(badge.flag or (badge.args and badge.args[1]), true)
world.clockDay = 2 -- TUESDAY
load("ROUTE_29", 20, 8)
assert(tuscanyOut(),
"MAPCALLBACK_OBJECTS did not put Tuscany on Route 29 on a Tuesday")
world.clockDay = 3 -- WEDNESDAY
load("ROUTE_29", 20, 8)
assert(not tuscanyOut(), "and she is still there on a Wednesday")
-- The badge is the outer gate: no badge, no sibling on any day.
world:setEngineFlag(badge.flag or (badge.args and badge.args[1]), false)
world.clockDay = 2
load("ROUTE_29", 20, 8)
assert(not tuscanyOut(),
"she appears without ZEPHYRBADGE, so the callback's first branch is dead")
world.clockDay = nil
U.log("OBJECTS: Tuscany is on Route 29 on Tuesdays, with the badge, only")
end
-- ---- MAPCALLBACK_CMDQUEUE ----------------------------------------------
-- Already driven end to end by gold_icepath_boulder; what belongs here is
-- that the map load still fills the queue from the EXTRACTED callback now
-- that the other four types run alongside it.
do
local CmdQueue = require("src.world.gen2.CmdQueue")
load("ICE_PATH_B1F", 9, 2)
assert(CmdQueue.count(world.cmdQueue) == 1,
("the Ice Path load left %d queue entries, want 1")
:format(CmdQueue.count(world.cmdQueue)))
assert(world:extractedCmdQueue(),
"and the entry did not come from the extracted callback")
load("NEW_BARK_TOWN", 13, 6)
assert(CmdQueue.count(world.cmdQueue) == 0,
"ClearCmdQueue: the queue must not survive a map load")
U.log("CMDQUEUE: the Ice Path stone table rides the map load and no other")
end
-- ---- the invariant -----------------------------------------------------
-- Nothing reachable from a callback may block: ScriptEvents runs inside the
-- map load with no frame to come back on. Vm:runCallback records any that
-- tries, and after eleven real map loads the ledger has to be empty.
do
local blocked = {}
for key in pairs(world.vm.blockedCallbacks or {}) do
blocked[#blocked + 1] = key
end
assert(#blocked == 0,
"map callbacks blocked: " .. table.concat(blocked, ", "))
local unknown = {}
for op in pairs(world.vm.unknownOps or {}) do unknown[#unknown + 1] = op end
assert(#unknown == 0,
"map callbacks reached unimplemented opcodes: "
.. table.concat(unknown, ", "))
end
U.log("PASS gold_map_callbacks")
love.event.quit()
end
+269
View File
@@ -0,0 +1,269 @@
-- Screenshots of every Gen 2 menu, for eyes that a test cannot replace.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_menu_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-menus (default)
--
-- The driver boots straight into the world (Game2 skips the cinema under
-- POKEPORT_DRIVER), gives the save enough content that the screens have
-- something to draw, then pushes each one and captures it.
local U = require("tests.drivers.util")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local BoxMenu = require("src.ui.gen2.BoxMenu")
local PackMenu = require("src.ui.gen2.PackMenu")
local PcMenu = require("src.ui.gen2.PcMenu")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
local Pokegear = require("src.ui.gen2.Pokegear")
local SaveMenu = require("src.ui.gen2.SaveMenu")
local StartMenu = require("src.ui.gen2.StartMenu")
local TrainerCard = require("src.ui.gen2.TrainerCard")
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-menus"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
-- Capture a state on its own, then take it back off the stack.
local function show(name, state)
game.stack:push(state)
shot(name)
game.stack:pop()
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- Give the save something to show: a party, a bag across pockets, badges,
-- dex progress, a phone number and an unlocked Pokegear.
local save = game.save
local pokemon = game.data.pokemon or {}
local function mon(species, level, hp)
local def = pokemon[species]
local maxHp = 20 + level
return {
species = species, name = def and def.name or species,
nickname = def and def.name or species,
level = level, hp = hp or maxHp, maxHp = maxHp,
}
end
save.party = {
mon("CYNDAQUIL", 12),
mon("TOTODILE", 10, 9),
mon("PIDGEY", 8, 2),
}
-- Bag ids are the CONSTANT names ItemAttributes is keyed by, not the printed
-- name: a TM's id is what it teaches (TM_DYNAMICPUNCH prints as "TM01"), so
-- seeding "TM01" here would leave the TM pocket empty and drop three unknown
-- rows into the ITEMS pocket instead. TOWN_MAP is not a bag item in Gold
-- either (the map is a Pokegear card, and item 6 is one of the unused
-- TERU-SAMA slots).
save.inventory = {
POTION = 5, SUPER_POTION = 2, ANTIDOTE = 1, FULL_HEAL = 1,
REVIVE = 1, ETHER = 2, X_ATTACK = 1, REPEL = 3,
POKE_BALL = 10, GREAT_BALL = 3, ULTRA_BALL = 1,
BICYCLE = 1, ITEMFINDER = 1, OLD_ROD = 1, COIN_CASE = 1,
SQUIRTBOTTLE = 1,
TM_DYNAMICPUNCH = 1, TM_HEADBUTT = 1, TM_ROCK_SMASH = 1,
HM_CUT = 1, HM_SURF = 1,
}
save.player.badges = { true, true }
save.player.money = 3210
save.player.id = 12345
save.player.name = "GOLD"
save.playTime = { hours = 4, minutes = 37, seconds = 0, frames = 0 }
-- The unlocks the way the game writes them: ENGINE_RADIO_CARD 0,
-- ENGINE_MAP_CARD 1, ENGINE_PHONE_CARD 2, ENGINE_POKEGEAR 4 and
-- ENGINE_POKEDEX 11 through the same store `setflag` lands in.
save.engineFlags = save.engineFlags or {}
for _, flag in ipairs({ 0, 1, 2, 4, 11 }) do
save.engineFlags[flag] = true
end
save.phoneContacts = { ELM = true, MOM = true }
for _, species in ipairs({ "CYNDAQUIL", "TOTODILE", "CHIKORITA", "PIDGEY",
"RATTATA", "SENTRET", "HOOTHOOT" }) do
save.pokedex.seen[species] = true
end
for _, species in ipairs({ "CYNDAQUIL", "TOTODILE", "PIDGEY" }) do
save.pokedex.caught[species] = true
end
-- The overworld itself, for reference.
shot("00-overworld")
-- Boot screens.
show("01-mainmenu-newgame", MainMenu.new(game, {
hasSave = false, save = false,
clock = { hour = 10, minute = 5, weekday = 3 },
}))
show("02-mainmenu-continue", MainMenu.new(game, {
hasSave = true, save = save,
clock = { hour = 20, minute = 42, weekday = 6 },
}))
local sprites = game.data.gen2Sprites
local chris = sprites and sprites.SPRITE_CHRIS
local Palettes = require("src.world.gen2.Palettes")
local naming = NamingScreen.new(game, {
type = "player",
menuGfx = game.data.gen2MenuGfx,
iconPath = chris and chris.image or nil,
iconColors = game.data.gen2Palettes
and Palettes.spritePalette(game.data.gen2Palettes, "DAY", chris) or nil,
})
naming.text = "GOL"
show("03-naming-upper", naming)
naming.lower = true
naming.row = 4
naming.col = 3
show("04-naming-lower-del", naming)
-- The movie is a state machine, so a still is "run it to frame N": one from
-- each act, picked where its cast is on screen.
local intro = GoldSilverIntro.new(game, {})
local function seek(target)
while intro.frames < target and not intro.done do intro:step() end
return intro
end
show("05-intro-water", seek(600))
show("06-intro-grass", seek(1500))
show("07-intro-fire", seek(2200))
-- In-game menus. The start menu is not opaque, so the overworld shows
-- through it -- which is exactly how it looks in play.
show("08-startmenu", StartMenu.new(game, { save = save }))
-- QUIT's confirmation, which is the port's own row rather than the cart's
-- EXIT: the yes/no defaults to NO so a stray A never throws away progress.
local quitting = StartMenu.new(game, { save = save })
quitting.phase = "confirm"
quitting.confirmChoice = 2
show("24-startmenu-quit", quitting)
show("09-party", PartyMenu.new(game, { prompt = "choose" }))
show("10-pack-items", PackMenu.new(game, { pocket = "ITEM" }))
show("11-pack-tms", PackMenu.new(game, { pocket = "TM_HM" }))
show("12-pokegear-clock", Pokegear.new(game, {
clock = { hour = 14, minute = 8, weekday = 2 },
currentLandmark = "LANDMARK_NEW_BARK_TOWN",
}))
local gear = Pokegear.new(game, {
currentLandmark = "LANDMARK_NEW_BARK_TOWN",
})
gear.cardIndex = 2
gear.mode = "card"
show("13-pokegear-map", gear)
-- The radio card, tuned and playing, and the phone mid-call.
local radio = Pokegear.new(game, {})
radio.mode = "card"
for i, card in ipairs(radio.cards) do
if card.id == "radio" then radio.cardIndex = i end
end
radio.station = 1
radio.radioLine = 2
show("13b-pokegear-radio", radio)
local phone = Pokegear.new(game, {})
phone.mode = "card"
for i, card in ipairs(phone.cards) do
if card.id == "phone" then phone.cardIndex = i end
end
phone:callContact((phone:phoneList() or {})[1])
show("13c-pokegear-phone", phone)
local card = TrainerCard.new(game, {})
show("14-trainercard", card)
card.page = 2
show("15-trainercard-badges", card)
show("16-pokedex", PokedexMenu.new(game, {}))
local dex = PokedexMenu.new(game, {})
dex.view = "entry"
for i, row in ipairs(dex.rows) do
if row.caught then dex.index = i break end
end
show("17-pokedex-entry", dex)
-- The two screens SELECT and START open (Pokedex_InitOptionScreen /
-- Pokedex_InitSearchScreen).
local dexOption = PokedexMenu.new(game, {})
dexOption.view = "option"
show("17b-pokedex-option", dexOption)
local dexSearch = PokedexMenu.new(game, {})
dexSearch.view = "search"
dexSearch.searchType = { 10, 0 } -- FIRE / -----
show("17c-pokedex-search", dexSearch)
show("18-options", OptionsMenu.new(game, { options = game.options }))
-- ...and scrolled to the port's own display rows, which is what the ▼ on
-- the first page points at.
local scrolled = OptionsMenu.new(game, { options = game.options })
scrolled.index = #OptionsMenu.ROWS
scrolled:ensureVisible()
show("25-options-display", scrolled)
-- writer is stubbed so the shot never touches a real save file.
show("19-save", SaveMenu.new(game, {
save = save, existed = false,
writer = function() return true end,
}))
-- The storage system: the PC's top menu, the box picker, and the withdraw
-- and deposit lists. Stock a box first so the list has rows and the left
-- panel has a pic to draw.
local Boxes = require("src.core.gen2.Boxes")
local stored = Boxes.box(save, 1)
for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA", "SENTRET" }) do
stored[i] = mon(species, 10 + i)
end
Boxes.rename(save, 2, "GRASS")
local pc = PcMenu.new(game, { save = save })
show("20-pc-menu", pc)
pc.picking = true
pc.pickIndex = 2
show("21-pc-changebox", pc)
show("22-pc-withdraw", BoxMenu.new(game, {
save = save, mode = "withdraw",
}))
show("23-pc-deposit", BoxMenu.new(game, {
save = save, mode = "deposit",
}))
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
-- SetDayOfWeek), each at its picker rather than at its opening page.
local clock = InitClock.new(game, { save = save })
clock.phase = "hour"
show("26-initclock-hour", clock)
clock.phase = "minute"
clock.minute = 25
show("27-initclock-minutes", clock)
clock.phase = "confirm-hour"
show("28-initclock-confirm", clock)
local wheel = InitClock.new(game, { mode = "day", save = save })
wheel.day = 2
show("29-dayofweek", wheel)
-- FLY's own picker (_FlyMap): the town map with the cursor on a visited
-- flypoint, not the yes/no chain the port used to fall back to.
local FieldMoves = require("src.world.gen2.FieldMoves")
save.engineFlags = save.engineFlags or {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
save.engineFlags[row.flag] = true
end
local points = FieldMoves.flyPoints(save, game.data.gen2Landmarks, "johto")
show("30-flymap", Pokegear.new(game, {
save = save,
currentLandmark = "LANDMARK_NEW_BARK_TOWN",
fly = points,
onFly = function() end,
onClose = function() end,
}))
print("[driver] PASS gold menu shots in " .. out)
end
+80
View File
@@ -0,0 +1,80 @@
-- The MeetMomScript cutscene, shot at the moments that used to go wrong.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_mom_scene.lua love .
--
-- Three things this is watching for, all of them general rather than
-- Mom-specific:
-- * an object whose event flag a RUNNING script flips must not swap on the
-- spot -- the cart only re-reads the object list on a map load, so Mom
-- stays standing beside you until she has walked back to her chair
-- * an object that appears mid-map must have its palette baked immediately,
-- not on the next once-a-second poll, or it stands there in greyscale
-- * a `yesorno` keeps the question on screen underneath the prompt
--
-- Shots land in /tmp/gold-mom.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-mom"
local function tap(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 4)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
U.shot(game, out .. "/00-bedroom.png")
-- Drop straight into the living room at the top of the stairs, which is
-- where MeetMomScript's coord event sits. The indoor route down from the
-- bedroom is a fragile way to reach a scene that is not about stairs.
world:setMap("PLAYERS_HOUSE_1F", 7, 3, "down")
U.wait(20)
for _ = 1, 3 do tap("down", 8) end
U.wait(40)
U.shot(game, out .. "/01-scene-start.png")
-- Page through until the first yes/no is up, shooting as we go.
local shots, sawChoice = 1, false
for step = 1, 200 do
local top = game.stack:top()
local isChoice = top and top.index ~= nil and top.onChoose ~= nil
-- A TextBox that has pushed its own choice box counts too.
if game.world.choicebox and not sawChoice then
sawChoice = true
U.shot(game, out .. "/02-yes-no.png")
end
if isChoice and not sawChoice then
sawChoice = true
U.shot(game, out .. "/02-yes-no.png")
end
if step % 25 == 0 then
shots = shots + 1
U.shot(game, ("%s/03-scene-%02d.png"):format(out, shots))
end
if not world:busy() and step > 20 then break end
tap("a", 4)
end
U.wait(30)
U.shot(game, out .. "/04-scene-end.png")
-- The two invariants, checked rather than eyeballed.
local greyed = {}
for _, npc in pairs(world.npcPool or {}) do
if npc.sprite and npc.spriteDef and not npc.sprite.objColors then
greyed[#greyed + 1] = npc.spriteDef.id or "?"
end
end
print(("[driver] %d pooled NPCs, %d without a baked palette")
:format((function() local n = 0 for _ in pairs(world.npcPool or {}) do n = n + 1 end return n end)(),
#greyed))
print(("[driver] saw a yes/no prompt: %s"):format(tostring(sawChoice)))
print("[driver] PASS gold mom scene in " .. out)
love.event.quit()
end
+144
View File
@@ -0,0 +1,144 @@
-- The three screens a test cannot see: MOVE POKéMON W/O MAIL, the PACK's item
-- submenu, and the EGG summary page.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_move_pack_egg.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-move-pack-egg (default)
--
-- Each one is here because its bug was invisible to a green test:
--
-- * MOVE POKéMON W/O MAIL (_MovePKMNWithoutMail, engine/pokemon/bills_pc.asm)
-- used to move the mon the instant it was chosen, to a box the player
-- never named, with the confirmation string computed and dropped. On
-- screen that is a PC that eats your Pokemon. The shots walk the cart's
-- four steps and print the census at each one, so the mon is accounted for
-- in the log as well as on the screen.
-- * The PACK (engine/items/pack.asm .ItemBallsKey_LoadSubmenu) had no item
-- submenu at all, so USE was the only verb and a TOSS was unreachable.
-- * The EGG page (EggStatsScreen) draws menu_gfx.eggHatch.egg, and a cache
-- imported before the extractor learned EggPic has no such file -- so the
-- pic block was blank. The shot proves the ICON_EGG fallback fills it.
local U = require("tests.drivers.util")
local BoxMenu = require("src.ui.gen2.BoxMenu")
local Boxes = require("src.core.gen2.Boxes")
local Mon = require("src.battle.gen2.Mon")
local PackMenu = require("src.ui.gen2.PackMenu")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-move-pack-egg"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.player.name = "GOLD"
save.player.id = 12345
local function build(species, level, fields)
local mon = Mon.new(game.data, species, level,
{ dvs = { attack = 15, defense = 15, speed = 15, special = 15 } })
assert(mon, "no base data for " .. species)
mon.nickname = mon.name
mon.otName = save.player.name
mon.otId = save.player.id
for key, value in pairs(fields or {}) do mon[key] = value end
return mon
end
-- Every mon in the save, party and boxes together. If this number ever
-- changes across a move, the PC ate one.
local function census()
local n = #(save.party or {})
for i = 1, Boxes.NUM_BOXES do n = n + Boxes.count(save, i) end
return n
end
save.party = {
build("CYNDAQUIL", 14),
build("TOTODILE", 12),
build("PIDGEY", 9),
}
local box = Boxes.box(save, 1)
box[1] = build("SENTRET", 6)
box[2] = build("HOOTHOOT", 7)
box[3] = build("GEODUDE", 8)
save.currentBox = 1
local before = census()
U.log(("[driver] %d mons before the move"):format(before))
-- ---- 1. MOVE POKéMON W/O MAIL -----------------------------------------
local move = BoxMenu.new(game, { save = save, mode = "move",
onClose = function() end })
game.stack:push(move)
shot("00-move-choose") -- "Choose a <PK><MN>."
U.tap(game, "a")
shot("01-move-submenu") -- MOVE / STATS / CANCEL, "What's up?"
U.tap(game, "a")
shot("02-move-to-where") -- the insert cursor, "Move to where?"
assert(census() == before, "the mon left the save before it was placed")
U.tap(game, "right")
shot("03-move-destination-box2") -- BOX2 named in the header
U.tap(game, "a")
shot("04-move-saving") -- "Saving… Leave ON!"
assert(census() == before, "a moved mon went missing")
assert(Boxes.count(save, 2) == 1, "nothing landed in BOX2")
U.tap(game, "a")
U.tap(game, "left")
U.tap(game, "left")
shot("05-move-party-list") -- box 0: the PARTY, which the old screen
-- could not reach at all
game.stack:pop()
U.log(("[driver] %d mons after the move (BOX2 holds %d)")
:format(census(), Boxes.count(save, 2)))
-- ---- 2. the PACK's item submenu ---------------------------------------
save.inventory = {
POTION = 5, SUPER_POTION = 2, REPEL = 3, POKE_BALL = 10,
BICYCLE = 1, ITEMFINDER = 1, HM_CUT = 1, TM_HEADBUTT = 1,
}
local pack = PackMenu.new(game, { save = save, onClose = function() end })
-- Rows sort by ItemNames index, so SUPER POTION (17) is above POTION (18);
-- park the cursor on the POTION by name rather than by position.
for i, row in ipairs(pack.rows) do
if row.id == "POTION" then pack.index = i end
end
game.stack:push(pack)
shot("06-pack-items")
U.tap(game, "a")
shot("07-pack-submenu") -- USE / GIVE / TOSS / QUIT
U.tap(game, "down")
U.tap(game, "down")
shot("08-pack-submenu-toss")
U.tap(game, "a")
shot("09-pack-toss-how-many") -- "Throw away how many?" + the counter
U.tap(game, "up")
U.tap(game, "up")
shot("10-pack-toss-count")
U.tap(game, "a")
shot("11-pack-toss-confirm") -- "Throw away 3 POTION(S)?" + YES/NO
U.tap(game, "a")
shot("12-pack-threw-away") -- "Threw away POTION(S)."
U.log(("[driver] POTIONs left: %s"):format(tostring(save.inventory.POTION)))
assert((save.inventory.POTION or 0) == 2, "the TOSS did not spend three")
game.stack:pop()
-- ---- 3. the EGG summary page ------------------------------------------
local egg = build("TOGEPI", 5, { isEgg = true, eggSteps = 20 })
egg.nickname = "EGG"
local summary = SummaryMenu.new(game, { mon = egg, save = save })
local gfx = (game.data.gen2MenuGfx or {}).eggHatch
U.log(("[driver] menu_gfx.eggHatch.egg = %s")
:format(tostring(gfx and gfx.egg)))
game.stack:push(summary)
shot("13-egg-summary")
game.stack:pop()
print("[driver] PASS gold move/pack/egg shots in " .. out)
end
@@ -0,0 +1,169 @@
-- Two things only a human can see, in one run.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_naming_and_trade_item.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Only the gold-dev identity has a complete Gold cache; another one is missing
-- data/generated/marts.lua, RomImporter never reports ready, and the driver
-- coroutine is simply never resumed (a silent hang with no output).
--
-- 1. The naming keyboard. Typing the LAST character does not close the screen:
-- `.a` is `call NamingScreen_TryAddCharacter / ret nc`, and
-- AdvanceCursor_CheckEndOfString answers CARRY once the buffer is full, so
-- the handler falls through into `.start` and parks the cursor on END with
-- the keyboard still up (engine/menus/naming_screen.asm:401-410). Only
-- `.end` stores the entry. The blank cells are typeable too: the NameInput*
-- rows are written into the tilemap and GetLastCharacter reads the tile
-- under the cursor back out, so the trailing spaces of "S T U V W X Y Z "
-- are real characters (data/text/name_input_chars.asm).
--
-- 2. Kyle's Onix (VioletKylesHouse, NPC_TRADE_KYLE) arrives holding
-- BITTER_BERRY. NPCTRADE_ITEM is an item id BYTE in the table
-- (data/events/npc_trades.asm:15) and DoNPCTrade copies it into
-- wPartyMon1Item; the port names it, so the summary's green page prints
-- BITTER BERRY and TAKE drops a real BITTER BERRY into the bag instead of
-- killing the game in Bag.isBadge.
--
-- Screenshots land in POKEPORT_SHOT_DIR (default /tmp/gold-naming-trade):
-- keyboard-parked-on-end.png full buffer, cursor bracketing END
-- keyboard-on-blank-cell.png the cursor sitting on the blank after Z
-- keyboard-typed-a-space.png a name with a space in the middle of it
-- onix-summary-item.png the green page's ITEM field
-- onix-item-taken.png "TOOK BITTER BERRY from ROCKY."
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local NpcTrade = require("src.core.gen2.NpcTrade")
local Screens = require("src.ui.Screens")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-naming-trade"
local fails = 0
local function ok(cond, msg)
if cond then print("[naming] ok " .. msg)
else fails = fails + 1 print("[naming] FAIL " .. msg) end
return cond
end
local function tap(btn) U.tap(game, btn) U.wait(3) end
local function top() return game.stack:top() end
U.wait(45)
ok(game.world and game.world.map, "gold world booted")
-- ---------------------------------------------------------- the keyboard
local typed
Screens.push(game, "Gen2NamingScreen", {
type = "rival",
menuGfx = game.data.gen2MenuGfx,
-- The pop is the caller's, exactly as World:nameRival does it.
onDone = function(name) game.stack:pop() typed = name end,
})
U.wait(10)
local keyboard = top()
if not ok(keyboard and keyboard.text == "", "the rival keyboard opened") then
error("gold naming: no keyboard, cannot continue")
end
-- Seven A presses on the A key fill a 7-character rival name.
for _ = 1, keyboard.maxLength do tap("a") end
ok(top() == keyboard, "a full buffer leaves the keyboard up")
ok(typed == nil, "and hands nothing back yet")
ok(keyboard:cursorCharacter() == "END", "the cursor parked itself on END")
U.shot(game, out .. "/keyboard-parked-on-end.png")
-- The blank cell after Z types a space. Back off two characters so the
-- buffer has room for a space AND a letter after it (a name with a gap in
-- the middle is the only way to see the space at all), then walk up out of
-- the bottom row to row 2, column 8.
tap("b")
tap("b")
tap("up")
tap("up")
tap("right")
tap("right")
ok(keyboard:cursorCharacter() == " ", "the cell after Z is a space")
U.shot(game, out .. "/keyboard-on-blank-cell.png")
tap("a")
ok(keyboard.text:sub(-1) == " ", "and A types it into the name")
-- Up twice from (8,2) is the I key, so the field ends up reading "AAAAA I".
tap("up")
tap("up")
tap("a")
ok(keyboard.text == "AAAAA I", "the space really is in the stored name")
U.shot(game, out .. "/keyboard-typed-a-space.png")
-- Only A on END ends entry.
tap("start")
tap("a")
ok(typed ~= nil and #typed == keyboard.maxLength,
"A on END is what stores the entry")
U.wait(5)
-- ------------------------------------------------------- Kyle's Onix
-- data/generated/events.lua, which World loads as its own eventTables.
local row = NpcTrade.row(game.world.eventTables, 1)
if not ok(row and row.get == "ONIX", "the cache carries NPC_TRADE_KYLE") then
error("gold naming: no trade row, cannot continue")
end
local save = game.save
save.party = { Mon.new(game.data, "BELLSPROUT", 12) }
local _, onix = NpcTrade.perform(game.data, save, row, 1)
ok(onix and onix.nickname == "ROCKY", "the trade handed ROCKY over")
ok(onix and onix.item == "BITTER_BERRY",
"wearing a NAMED BITTER_BERRY, not the table's byte 83")
-- START > POKeMON > A > STATS, then the green page.
tap("start")
local menu = top()
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
tap("a")
local party = top()
if not ok(party and party.screenId == "Gen2PartyMenu",
"the party list opened") then
error("gold naming: no party list, cannot continue")
end
tap("a")
tap("a") -- STATS leads the submenu
local summary = top()
ok(summary and summary.screenId == "Gen2SummaryMenu", "STATS opened STATS")
tap("right") -- page 1 (pink) -> page 2 (green), which is the ITEM page
U.wait(5)
local placed = summary and summary:placements()
local text = {}
for _, p in ipairs(placed or {}) do text[#text + 1] = tostring(p.text or "") end
ok(table.concat(text, "|"):find("BITTER BERRY", 1, true) ~= nil,
"the green page prints BITTER BERRY, not 83")
U.shot(game, out .. "/onix-summary-item.png")
tap("b")
-- ITEM > TAKE. This is the press that used to crash in Bag.isBadge.
tap("a")
local submenu = party and party.submenu
for _ = 1, 8 do
if submenu and submenu.items[submenu.index]
and submenu.items[submenu.index].id == "ITEM" then break end
tap("down")
submenu = party and party.submenu
end
ok(submenu and submenu.items[submenu.index]
and submenu.items[submenu.index].id == "ITEM", "the cursor found ITEM")
tap("a")
local held = top()
ok(held and held.screenId == "Gen2HeldItemMenu", "GIVE / TAKE opened")
tap("down")
tap("a")
U.wait(5)
ok(save.party[1].item == nil, "TAKE pulled the berry off")
ok((save.inventory or {}).BITTER_BERRY == 1, "and it landed in the bag")
U.shot(game, out .. "/onix-item-taken.png")
print(("[naming] %d failure(s)"):format(fails))
love.event.quit()
end
+185
View File
@@ -0,0 +1,185 @@
-- A move the target is immune to, and the two move lock-ins, on the real
-- battle screen.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_noeffect_anim.lua love .
--
-- What a human is here to see:
--
-- 01 / 02 TACKLE (NORMAL) against a GASTLY (GHOST). The move text prints,
-- the screen holds still for MoveDelay, and then "It doesn't affect
-- GASTLY..." appears. No attack animation plays at any point.
-- BattleCommand_Stab's `.GotMatchup` writes wAttackMissed for a
-- zero matchup (effect_commands.asm:1337), `stab` runs ahead of
-- `moveanim` in every damaging effect list
-- (data/moves/effects.asm:5), and BattleCommand_MoveAnimNoSub
-- early-outs on wAttackMissed (:1958).
-- 03 LEECH SEED on a Grass type: same shape, `.grass` ->
-- AnimateFailedMove (move_effects/leech_seed.asm).
-- 04 - 09 ROLLOUT. CheckPlayerLockedIn quits ParsePlayerAction while
-- SUBSTATUS_ROLLOUT is set (core.asm:546), so the FIGHT menu never
-- comes back at all: after the one selection in 04 the move repeats
-- on its own for four more turns and the menu only returns once the
-- fifth hit clears the bit (09). The PP counter moves exactly once,
-- on the opening turn, because checkrollout skips past
-- doturn_command for every later turn of the lock.
--
-- The animation suppression is the deliverable here: no headless assertion can
-- see whether a sprite moved, so this driver is the check.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-noeffect-anim"
local function tap(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
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- One mon carrying exactly the three moves this driver exercises, so the
-- FIGHT list is readable in the shots.
local player = Mon.new(game.data, "CYNDAQUIL", 30)
assert(player, "could not build a CYNDAQUIL from pokemon.lua")
player.moves = {
{ id = "TACKLE", pp = 35, maxPp = 35 },
{ id = "LEECH_SEED", pp = 10, maxPp = 10 },
{ id = "ROLLOUT", pp = 20, maxPp = 20 },
}
game.save.party = { player }
game.save.inventory = {}
-- Waits for the battle screen to be sitting on its menu again.
local function toMenu(battle, limit)
for _ = 1, (limit or 400) do
if battle.phase == "menu" or battle.battle.over then return end
tap("a", 3)
end
end
local function fight(battle, slot)
tap("a") -- FIGHT
U.wait(6)
for _ = 2, slot do tap("down", 4) end
U.wait(4)
return slot
end
local function openBattle(species, level)
local wild = Mon.new(game.data, species, level)
assert(wild, "could not build a wild " .. species)
assert(world:startBattle({ wild = wild }), "startBattle failed")
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
assert(battle and battle.battle, "battle screen never came up")
toMenu(battle)
return battle, wild
end
-- ---- immunity: NORMAL into GHOST ---------------------------------------
local battle = openBattle("GASTLY", 8)
U.shot(game, out .. "/00-menu.png")
fight(battle, 1) -- TACKLE
tap("a")
-- Straight after the "used TACKLE!" line is exactly where the animation
-- would be. Both shots must show a still screen.
U.wait(6)
U.shot(game, out .. "/01-tackle-no-anim.png")
U.wait(24)
U.shot(game, out .. "/02-doesnt-affect.png")
toMenu(battle)
-- ---- LEECH SEED into a Grass type --------------------------------------
for _ = 1, 200 do
if not (game.stack:top() and game.stack:top().battle) then break end
tap("b", 3)
if battle.battle.over then break end
tap("a", 3)
end
U.wait(30)
battle = openBattle("BELLSPROUT", 8)
fight(battle, 2) -- LEECH SEED
tap("a")
U.wait(6)
U.shot(game, out .. "/03-leech-seed-no-anim.png")
toMenu(battle)
-- ---- ROLLOUT locks the FIGHT list --------------------------------------
--
-- A high-level target so the five turns actually happen.
for _ = 1, 200 do
if not (game.stack:top() and game.stack:top().battle) then break end
tap("b", 3)
if battle.battle.over then break end
tap("a", 3)
end
U.wait(30)
battle = openBattle("SNORLAX", 40)
-- Both HP pools are widened first. ROLLOUT's power doubles every turn
-- (BattleCommand_RolloutPower), and at these levels either side faints inside
-- the five, which ends the battle and leaves the deliverable unshot: the lock
-- is what this segment is here to photograph, not a damage race.
local function widen(mon)
if not mon then return end
mon.stats = mon.stats or {}
mon.stats.hp, mon.maxHp, mon.hp = 999, 999, 999
end
widen(battle.battle.player)
widen(battle.battle.enemy)
local ppBefore = player.moves[3].pp
tap("a") -- FIGHT
U.wait(8)
U.shot(game, out .. "/04-rollout-picked-once.png")
tap("down", 4)
tap("down", 4)
tap("a") -- ROLLOUT, the only selection
-- From here the player never chooses again. A is still tapped, but only to
-- page the text along: if the menu ever reappears while the bit is set, the
-- port has lost CheckPlayerLockedIn. `rolloutLock` is the port's name for
-- SUBSTATUS_ROLLOUT and `rampCount` its counter minus one, so
-- `rampCount + 1` is the cart's wPlayerRolloutCount.
local menuDuringLock, shot, armed = false, {}, false
for _ = 1, 600 do
if battle.battle.over then break end
local v = player.volatile or {}
if v.rolloutLock then
armed = true
if battle.phase == "menu" then menuDuringLock = true end
local count = (v.rampCount or 0) + 1
if not shot[count] then
shot[count] = true
U.shot(game, ("%s/0%d-rollout-turn%d.png"):format(out, 4 + count, count))
end
elseif armed then
-- The fifth hit is the one that clears the bit, so the loop leaves on it
-- and 09 below is the menu coming back.
break
end
tap("a", 3)
end
toMenu(battle)
U.shot(game, out .. "/09-lock-released.png")
local spent = ppBefore - player.moves[3].pp
print(("[driver] %s the opening hit set SUBSTATUS_ROLLOUT")
:format(armed and "ok " or "FAIL"))
print(("[driver] %s the FIGHT menu stayed shut for the whole lock")
:format(menuDuringLock and "FAIL" or "ok "))
print(("[driver] %s ROLLOUT spent %d PP (1 is the cart: only turn one pays)")
:format(spent == 1 and "ok " or "FAIL", spent))
print("[driver] shots in " .. out)
end
+114
View File
@@ -0,0 +1,114 @@
-- The five full-screen Gold pages that used to letterbox over a live
-- overworld: the Ruins of Alph sliding puzzle, the DIPLOMA, the MAGNET TRAIN
-- ride, the Cianwood PHOTO card and the ALPH RUINS STAMP viewer.
--
-- Every one of them wipes the tilemap on the cart before it draws a single
-- tile -- ClearBGPalettes / ClearTilemap at engine/games/unown_puzzle.asm:11,
-- engine/events/diploma.asm:13, engine/events/magnet_train.asm:101,
-- engine/printer/print_party.asm:134 and engine/events/print_unown.asm:17 --
-- so no map pixel can be on screen while one is up. In the port that means
-- src/core/Game2.lua:drawScene must never reach `world:draw()` while an
-- opaque screen owns the stack.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_opaque_surround.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-surround (default)
--
-- The screenshots are the deliverable: each one must show its page centred in
-- a plain field with NO Route 31 grass, ledge or house around it. The
-- assertions catch the same thing from inside (the overworld draw is counted
-- while the page is up, and has to stay at zero), so a run nobody watches is
-- still worth something.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-surround"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[surround] ok " .. label)
else
failures = failures + 1
print("[surround] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- ROUTE_31 on purpose: grass, a ledge and a house, so a leak is obvious in
-- the shot rather than a subtle band of colour.
assert(world:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31")
U.wait(8)
-- Count the overworld draws the way drawScene issues them. The instance
-- entry shadows World.draw on the metatable, so this sees every call and
-- still runs the real one.
local drew = 0
local worldDraw = world.draw
world.draw = function(self, ...)
drew = drew + 1
return worldDraw(self, ...)
end
-- Control: the overworld IS the visible base here, so it must draw. Without
-- this the "never drew" assertions below would pass on a dead renderer.
drew = 0
U.shot(game, out .. "/00-overworld.png")
ok("the plain overworld still draws the map", drew > 0, drew)
-- `settle` is how many frames the page needs before it has anything to
-- show: the still pages are ready at once, the Magnet Train has to run its
-- scroll far enough for the carriage to exist.
local function page(label, file, open, settle)
if not open() then
failures = failures + 1
print("[surround] FAIL " .. label .. " did not open")
return
end
U.wait(settle or 12)
local base = game.stack._items[game.stack:visibleBase()]
ok(label .. " is the opaque visible base",
base ~= nil and base.isOpaque == true, base and base.isOpaque)
drew = 0
U.shot(game, out .. "/" .. file)
ok(label .. " keeps the overworld off the screen (ClearTilemap)",
drew == 0, drew)
if game.stack:top() ~= game.overworld then game.stack:pop() end
U.wait(6)
end
-- engine/games/unown_puzzle.asm _UnownPuzzle
page("the sliding puzzle", "01-unown-puzzle.png", function()
return world:unownPuzzle(0, function() end)
end)
-- engine/events/diploma.asm PlaceDiplomaOnScreen
page("the DIPLOMA", "02-diploma.png", function()
return world:showDiploma(function() end)
end)
-- engine/events/magnet_train.asm MagnetTrain_LoadGFX_PlayMusic
page("the MAGNET TRAIN ride", "03-magnet-train.png", function()
return world:magnetTrain(true, function() end)
end, 90)
-- engine/printer/print_party.asm PrintPartyMonPage1
page("the PHOTO card", "04-photo-studio.png", function()
local party = game.save and game.save.party
return world:showPhotoStudio(party and party[1], function() end)
end)
-- engine/events/print_unown.asm _UnownPrinter
page("the ALPH RUINS STAMP", "05-unown-printer.png", function()
return world:showUnownPrinter(function() end)
end)
world.draw = worldDraw
print(failures == 0 and "PASS gold_opaque_surround"
or ("FAIL gold_opaque_surround (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+114
View File
@@ -0,0 +1,114 @@
-- Assertion driver: object hour windows, the temporary event-flag byte, and
-- the Route 30 roadblock's facing, all through real map loads in the running
-- game. PASSES or errors; nothing to eyeball.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_overworld_npc.lua love .
--
-- tests/gen2_object_hours_test.lua and tests/gen2_temp_events_test.lua prove
-- the same rules over fixtures and a bare cache; what only this can prove is
-- that a genuine boot, cache and setMap chain agree with them: CheckObjectTime
-- filters the spawn (home/map_objects.asm), ResetMapBufferEventFlags clears
-- flags 0-7 on the load (home/map.asm), and the spoken-to roadblock MONSTER
-- turns to the player (ObjectEvent's jumptextfaceplayer, home/map.asm).
local U = require("tests.drivers.util")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local function momCount()
local n = 0
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.sprite == "SPRITE_MOM" then n = n + 1 end
end
return n
end
-- ---- hour windows: one Mom, whatever the hour --------------------------
-- Post-intro state: EVENT_PLAYERS_HOUSE_MOM_1 (1735) hides the intro Mom,
-- EVENT_PLAYERS_HOUSE_MOM_2 (1736) clear shows the time-of-day set. Both
-- scene maps go to their NOOP scene first, or the MeetMom / Elm's-aide
-- walk-ups fire on the load and park a text box over the whole run.
world.mapScenes = world.mapScenes or {}
world.mapScenes.PLAYERS_HOUSE_1F = 1
world.mapScenes.NEW_BARK_TOWN = 1
world.events:set(1735, true)
world.events:set(1736, false)
for _, hour in ipairs({ 6, 12, 20 }) do
world.clockHour = hour
assert(world:setMap("PLAYERS_HOUSE_1F", 3, 3, "down"),
"PLAYERS_HOUSE_1F did not load")
U.wait(2)
local n = momCount()
assert(n == 1, ("%02d:00 spawned %d Moms, want exactly 1"):format(hour, n))
end
U.log("hour windows: one Mom in the kitchen at 06:00, 12:00 and 20:00")
local function pharmacists()
local n = 0
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.sprite == "SPRITE_PHARMACIST" then n = n + 1 end
end
return n
end
for _, row in ipairs({ { 6, 0 }, { 12, 1 }, { 20, 1 } }) do
world.clockHour = row[1]
assert(world:setMap("GOLDENROD_GAME_CORNER", 8, 10, "up"),
"GOLDENROD_GAME_CORNER did not load")
U.wait(2)
local n = pharmacists()
assert(n == row[2],
("game corner at %02d:00: %d pharmacists, want %d")
:format(row[1], n, row[2]))
end
U.log("hour windows: the pharmacist pair collapses to one, absent at dawn")
-- ---- the temporary byte dies on the load --------------------------------
for id = 0, 8 do world.events:set(id, true) end
assert(world:setMap("NEW_BARK_TOWN", 8, 8, "down"),
"NEW_BARK_TOWN did not load")
U.wait(2)
for id = 0, 7 do
assert(not world.events:get(id),
("temporary flag %d survived the map load"):format(id))
end
assert(world.events:get(8), "flag 8 must survive: only one byte clears")
world.events:set(8, false)
U.log("temp events: flags 0-7 cleared by the load, flag 8 kept")
-- ---- the roadblock Rattata turns to the player --------------------------
-- EVENT_ROUTE_30_BATTLE (1812) clear puts the battling pair on the map.
world.events:set(1812, false)
world.clockHour = 12
assert(world:setMap("ROUTE_30", 4, 25, "right"), "ROUTE_30 did not load")
U.wait(2)
local rattata
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.sprite == "SPRITE_MONSTER"
and npc.cellX == 5 and npc.cellY == 25 then
rattata = npc
end
end
assert(rattata, "the (5,25) roadblock MONSTER did not spawn")
assert(rattata.facing == "up",
"before the talk it faces its partner (STANDING_UP), got " .. rattata.facing)
U.tap(game, "a")
U.wait(4)
assert(rattata.facing == "left",
"spoken to from the west it must turn left, got " .. rattata.facing)
assert(world:busy(), "and its ObjectEvent text box is up")
assert(rattata.frozen, "and it holds still under the box")
-- Type the line out and close the box (held A is the fast path).
for _ = 1, 120 do
if not world:busy() then break end
U.tap(game, "a")
end
assert(not world:busy(), "the box closed")
U.wait(4)
assert(not rattata.frozen, "and the freeze lifted with the script")
U.log("PASS gold_overworld_npc: hour windows, temp flags, roadblock facing")
love.event.quit(0)
end
+70
View File
@@ -0,0 +1,70 @@
-- Gold GBC palettes: one screenshot of New Bark Town per time of day, plus
-- one of Elm's lab (PALETTE_DAY, so it must NOT go dark at night).
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_palette_shots.lua love .
--
-- Palettes are the one part of the Gen 2 port a test cannot assert -- "is the
-- roof the right blue at 6am" only a human can answer -- so this driver's job
-- is to put those four frames on disk side by side.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-palettes"
local function world()
return game.world
end
local function rebake(hour)
local w = world()
w.clockHour = hour
-- Force the re-resolve the once-a-second poll would eventually do.
w.paletteClock = 0
if w:applyPalettes() then
w.mapImages = {}
w.mapImage = w:imageFor(w.map.id)
w:rebuildNeighbors()
end
return w.daytime
end
U.wait(45)
assert(world() and world().map, "gold world did not boot")
-- A New Game starts in the bedroom now (SPAWN_HOME); step outside, which is
-- where the time-of-day palettes are worth looking at.
if world().map.id ~= "NEW_BARK_TOWN" then
world():setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(15)
end
assert(world().map.id == "NEW_BARK_TOWN",
"boot map " .. tostring(world().map.id))
if not world().palettes then
print("[driver] SKIP no palettes.lua in this cache -- re-import Gold")
return
end
for _, entry in ipairs({
{ hour = 6, name = "morn" },
{ hour = 13, name = "day" },
{ hour = 21, name = "nite" },
}) do
local daytime = rebake(entry.hour)
U.wait(4)
U.shot(game, ("%s/newbark-%s.png"):format(out, entry.name))
print(("[driver] %s (hour %d) -> %s"):format(entry.name, entry.hour, daytime))
assert(daytime == entry.name:upper(),
("hour %d resolved to %s"):format(entry.hour, tostring(daytime)))
end
-- Elm's lab is PALETTE_DAY: walking in at 9pm must still be lit like day.
rebake(21)
world():setMap("ELMS_LAB", 4, 6, "up")
U.wait(10)
assert(world().daytime == "DAY",
"ELMS_LAB at 21:00 should stay PALETTE_DAY, got "
.. tostring(world().daytime))
U.shot(game, out .. "/elmslab-night-is-day.png")
print("[driver] PASS gold palette shots in " .. out)
end
+105
View File
@@ -0,0 +1,105 @@
-- The held-item marker on the party list's mon icons (.SpawnItemIcon,
-- engine/gfx/mon_icons.asm): a mon carrying something swaps its icon's
-- BOTTOM-LEFT tile for HeldItemIcons $09 (gfx/stats/item.2bpp), and one
-- carrying MAIL swaps it for $08 (gfx/stats/mail.2bpp) instead. Nothing about
-- it is text, so only a screenshot can say whether it is there.
--
-- Slot 1 holds a BERRY, slot 2 holds FLOWER MAIL, slot 3 holds nothing: one
-- shot with all three rows on screen is the whole comparison.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_party_held_item.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- The markers cannot appear until the ROM has been re-imported with a manifest
-- that lists HeldItemIcons: the driver says so out loud rather than leaving a
-- blank shot to be misread.
local U = require("tests.drivers.util")
return function(game)
local fails = 0
local function ok(cond, msg)
if cond then print("[held] ok " .. msg)
else fails = fails + 1 print("[held] FAIL " .. msg) end
return cond
end
local function tap(btn) U.tap(game, btn) U.wait(3) end
local function top() return game.stack:top() end
U.wait(45)
ok(game.world and game.world.map, "gold world booted")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
local save = game.save
save.party = {
Mon.new(game.data, "CYNDAQUIL", 12),
Mon.new(game.data, "TOTODILE", 10),
Mon.new(game.data, "GEODUDE", 8),
}
save.party[1].item = "BERRY"
-- The letter itself rides sPartyMail, keyed by slot; the icon only reads the
-- item byte, but a mon holding mail with no struct behind it is not a state
-- the cart can reach, so write both.
save.party[2].item = "FLOWER_MAIL"
Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!",
save.player and save.player.name or "GOLD",
save.player and save.player.id or 0, "TOTODILE"))
ok(Mail.monHoldsMail(save.party[2]), "slot 2 is holding mail")
ok(save.party[3].item == nil, "slot 3 is holding nothing")
-- GetIconGFX uploads HeldItemIcons as the two tiles after each icon's eight,
-- so the marker sheet rides the same cache entry the icons do.
local icons = game.data.gen2Icons
local hasMarkers = icons and icons.heldItem and icons.heldItem.image ~= nil
if hasMarkers then
print("[held] ok the cache carries HeldItemIcons: "
.. tostring(icons.heldItem.image))
else
print("[held] NOTE this cache predates the HeldItemIcons extraction, so "
.. "the icons will be bare. Re-import the ROM before reading the shot.")
end
-- START > POKéMON, the field flavour of the list.
tap("start")
local menu = top()
ok(menu and menu.screenId == "Gen2StartMenu", "START opened the menu")
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
tap("a")
local party = top()
if not ok(party and party.screenId == "Gen2PartyMenu",
"POKéMON opened the list") then
error("gold party held item: no party list, cannot continue")
end
-- ItemIsMail is what picks between the two tiles; row 0 of the sheet is
-- mail.2bpp and row 1 is item.2bpp, the order they are INCBIN'd.
ok(party.heldMarkerRow(save.party[1]) == 1, "the berry asks for tile $09")
ok(party.heldMarkerRow(save.party[2]) == 0, "the mail asks for tile $08")
ok(party.heldMarkerRow(save.party[3]) == nil, "the empty hand asks for none")
-- The cursor sits on row 1, which slides its icon a tile right; wait out a
-- full frame swap first so the shot catches the icons mid-animation and the
-- marker can be checked for NOT bobbing with them.
U.wait(20)
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-party")
.. "/held-item-markers.png")
tap("down")
U.wait(20)
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-party")
.. "/held-item-markers-row2.png")
tap("b")
tap("b")
if fails > 0 then
error(("gold party held item: %d assertion(s) failed"):format(fails))
end
print("[driver] PASS gold party held item: berry, mail and an empty hand")
end
+134
View File
@@ -0,0 +1,134 @@
-- The field party list, end to end through the pad: START > POKéMON opens
-- the list, A on a mon opens PokemonActionSubmenu (engine/pokemon/
-- mon_menu.asm), STATS pushes the summary, SWITCH reorders the save's own
-- party, and an EGG slot offers only STATS / SWITCH / CANCEL with an EGG row
-- and the EGG icon (engine/pokemon/party_menu.asm, mon_submenu.asm .egg).
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_party_submenu.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local U = require("tests.drivers.util")
return function(game)
local fails = 0
local function ok(cond, msg)
if cond then print("[party] ok " .. msg)
else fails = fails + 1 print("[party] FAIL " .. msg) end
return cond
end
local function tap(btn) U.tap(game, btn) U.wait(3) end
local function top() return game.stack:top() end
U.wait(45)
ok(game.world and game.world.map, "gold world booted")
-- A real party out of the one Gen 2 builder, and the egg out of the same
-- giveegg builder the aide's script calls (World:giveEgg, species index
-- 175 = TOGEPI).
local Mon = require("src.battle.gen2.Mon")
local save = game.save
save.party = {
Mon.new(game.data, "CYNDAQUIL", 12),
Mon.new(game.data, "TOTODILE", 10),
}
ok(game.world:giveEgg(175, 5), "giveegg filled slot 3")
ok(save.party[3] and save.party[3].isEgg == true, "and marked it an egg")
-- The cache carries the egg's own menu icon (ICON_EGG, IconPointers).
local icons = game.data.gen2Icons
ok(icons and icons.icons and icons.icons.ICON_EGG
and icons.icons.ICON_EGG.image ~= nil, "the cache has ICON_EGG")
-- START opens the menu; walk the cursor to the POKéMON row.
tap("start")
local menu = top()
ok(menu and menu.screenId == "Gen2StartMenu", "START opened the menu")
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
ok(menu.list:current().value == "pokemon", "the cursor found POKéMON")
tap("a")
local party = top()
if not ok(party and party.screenId == "Gen2PartyMenu",
"POKéMON opened the list") then
error("gold party submenu: no party list, cannot continue")
end
ok(party.wantsSubmenu == true, "as the field flavour")
-- The EGG row is a name and an icon alone.
if party then
local eggRow = party.rowFor(save.party[3])
ok(eggRow.name == "EGG" and eggRow.hp == nil and eggRow.status == nil,
"the egg's row reads EGG with no HP or FNT")
ok(party:iconIdFor(save.party[3]) == "ICON_EGG",
"and draws the EGG icon")
end
-- A on the lead mon: the submenu, not an exit.
tap("a")
ok(top() == party, "a kept the list open")
ok(party and party.submenu ~= nil, "and opened the submenu")
ok(party and party.submenu
and party.submenu.items[1].id == "STATS", "STATS leads it")
-- STATS pushes the summary over the list.
tap("a")
local summary = top()
ok(summary and summary.screenId == "Gen2SummaryMenu", "STATS opened the summary")
ok(summary and summary.mon and summary.mon.species == "CYNDAQUIL",
"on the chosen mon")
tap("b")
ok(top() == party, "b landed back on the list")
-- SWITCH: hold slot 1, drop it on slot 2.
tap("a")
tap("down")
ok(party and party.submenu
and party.submenu.items[party.submenu.index].id == "SWITCH",
"the cursor found SWITCH")
tap("a")
ok(party and party.submenu == nil and party.switchFrom == 1,
"SWITCH holds the slot")
tap("down")
tap("a")
ok(save.party[1].species == "TOTODILE"
and save.party[2].species == "CYNDAQUIL", "the party reordered")
ok(party and party.switchFrom == nil, "and the hold released")
-- The egg's own submenu: STATS / SWITCH / CANCEL, and STATS shows the EGG
-- page with no species anywhere on it.
tap("down")
ok(party and party.index == 3, "the cursor reached the egg")
tap("a")
local items = party and party.submenu and party.submenu.items or {}
ok(#items == 3 and items[1].id == "STATS" and items[2].id == "SWITCH"
and items[3].id == "CANCEL", "an egg offers STATS / SWITCH / CANCEL")
tap("a")
summary = top()
ok(summary and summary.screenId == "Gen2SummaryMenu", "STATS on the egg opened")
if summary and summary.screenId == "Gen2SummaryMenu" then
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
local page = summary:placements()
ok(SummaryMenu.at(page, 8, 1) == "EGG", "as the EGG page")
ok(SummaryMenu.at(page, 8, 2) == nil
and SummaryMenu.at(page, 10, 4) == nil, "with the species kept secret")
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-party")
.. "/egg-summary.png")
tap("b")
end
ok(top() == party, "b came back to the list")
-- Back out of everything.
tap("b")
ok(top() ~= party, "b closed the list")
tap("b")
if fails > 0 then
error(("gold party submenu: %d assertion(s) failed"):format(fails))
end
print("[driver] PASS gold party submenu: STATS, SWITCH and the EGG rules")
end
+154
View File
@@ -0,0 +1,154 @@
-- Assertion driver: the phone in the RUNNING game. It PASSes or it fails
-- loudly; there is nothing to eyeball.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_phone_call.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Three things the ROM-free suites cannot see from outside a booted world:
-- * StartMap's `farcall InitCallReceiveDelay`: a genuine World:setMap has
-- to arm the receive countdown off the game clock.
-- * CheckTimeEvents' CheckPhoneCall arm: with a contact in the book and
-- the countdown run down, a random call has to RING in the overworld --
-- the caller-ID page, the extracted bank $41 chat, the Click! -- and a
-- caller script's .WantsBattle has to arm its _READY_FOR_REMATCH event.
-- * The Pokegear's CALL entry: the callee's SCRIPT1 runs through the same
-- VM while the card keeps the screen.
local U = require("tests.drivers.util")
return function(game)
local fails = 0
local function ok(cond, msg)
if cond then print("[phone] ok " .. msg)
else fails = fails + 1 print("[phone] FAIL " .. msg) end
return cond
end
local function tap(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 4)
end
-- TextBox.paginate emits a page as a LIST of wrapped lines; flatten the
-- whole open box to one searchable string.
local function boxText(top)
if not (top and top.pages) then return nil end
local out = {}
for _, page in ipairs(top.pages) do
if type(page) == "table" then
out[#out + 1] = table.concat(page, "\n")
else
out[#out + 1] = tostring(page)
end
end
return table.concat(out, "|")
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
local Phone = require("src.core.gen2.Phone")
-- The phone reads its clock through World:stepContext's game.clock seam, so
-- the driver owns the minutes the countdown walks.
game.clock = { day = 0, hour = 9, minute = 0 }
-- ------------------------------------------- StartMap arms the countdown
save.phone = nil
assert(w:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31")
U.wait(3)
ok(save.phone and save.phone.delayMins == 20,
"a map load arms the receive countdown at twenty minutes")
ok(save.phone and save.phone.timeCycles == 0, "with the cycle counter zeroed")
ok(save.phone and save.phone.delayStart
and save.phone.delayStart.minute == 0, "stamped off the game clock")
-- CheckStandingOnEntrance must not refuse, so stand on a plain floor cell.
local cx, cy = w.player.cellX, w.player.cellY
for y = 2, 16 do
for x = 2, 16 do
if w.map:cellCollision(x, y) == 0x00 then cx, cy = x, y end
end
end
assert(w:setMap("ROUTE_31", cx, cy, "down"), "no floor cell on ROUTE_31")
U.wait(3)
-- ------------------------------------------- a random call rings
-- Joey (contact 15) lives on ROUTE_30, so he is available from ROUTE_31,
-- and his caller script's rematch gate is ENGINE_FLYPOINT_GOLDENROD
-- (checkflag 69 in the extracted body).
Phone.addContact(save, 15)
w:setEngineFlag(69, true)
local calls, sawRing, sawClick, sawReset = 0, false, false, false
for _ = 1, 40 do
if w.events:get(628) then break end
game.clock.minute = game.clock.minute + 20
for _ = 1, 20 do
U.wait(1)
if w:busy() then break end
end
if w:busy() then
calls = calls + 1
for _ = 1, 300 do
local textAll = boxText(game.stack and game.stack:top())
if textAll then
if textAll:find("RING!", 1, true) then sawRing = true end
if textAll:find("Click!", 1, true) then sawClick = true end
end
if not w:busy() then break end
tap("a", 2)
end
if save.phone.timeCycles == 0 and save.phone.delayMins == 20 then
sawReset = true
end
end
end
ok(calls > 0, ("a random incoming call rang in the overworld (%d calls)")
:format(calls))
ok(sawRing, "opening on the RING! caller-ID page")
ok(sawClick, "and hanging up on the Click!")
ok(w.events:get(628),
"a caller script armed EVENT_JOEY_READY_FOR_REMATCH (628)")
ok(sawReset, "and the hang-up restarted the receive countdown")
-- ------------------------------------------- the Pokegear calls out
w:setEngineFlag(2, true) -- ENGINE_PHONE_CARD
w:setEngineFlag(4, true) -- ENGINE_POKEGEAR
game:openStartMenuItem("pokegear")
U.wait(3)
local gear = game.stack:top()
assert(gear and gear.cards, "the Pokegear did not open")
gear.mode = "card"
for index, card in ipairs(gear.cards) do
if card.id == "phone" then gear.cardIndex = index end
end
U.wait(2)
tap("a", 3) -- CALL/DELETE/CANCEL submenu on Joey's slot
tap("a", 3) -- CALL
local spoke = false
for _ = 1, 200 do
local top = game.stack and game.stack:top()
local textAll = boxText(top)
if textAll and textAll:find("JOEY", 1, true) then spoke = true end
if not (w.vm and w.vm:running()) and not (top and top.pages) then break end
tap("a", 2)
end
ok(gear.call ~= nil, "the card holds the placed call")
ok(gear.call and gear.call.script == "JoeyPhoneCalleeScript",
"to Joey's own SCRIPT1")
ok(gear.call and gear.call.ranScript == true,
"which ran through the overworld VM")
ok(spoke, "and he actually talked")
tap("a", 3) -- hang up
ok(gear.call == nil, "A hangs the call up")
print(fails == 0 and "PASS gold_phone_call"
or ("FAIL gold_phone_call (%d)"):format(fails))
love.event.quit(fails == 0 and 0 or 1)
end
+128
View File
@@ -0,0 +1,128 @@
-- The caller-ID box an incoming call puts across the top of the screen:
-- Phone_TextboxWithName (pokegold engine/phone/phone.asm:582), reached from
-- RingTwice_StartCall's .CallerTextboxWithName (:466, :474).
--
-- Three shots, because the whole bug was "there is no box at all" and only a
-- picture can answer that:
--
-- 01-ring.png the ring, box up, naming who is calling
-- 02-talking.png the caller's own script talking UNDER the box
-- 03-after.png the call over, box gone, overworld clean
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_phone_caller_box.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-callerbox (default)
--
-- The assertions cover the same ground for a run nobody is looking at: the box
-- is on the stack while the call runs, it is UNDER the text pages rather than
-- over them, and it is off the stack once the script has ended (a box left
-- behind would sit on the overworld forever -- it has no `update`, so nothing
-- would ever take it down).
local U = require("tests.drivers.util")
local Phone = require("src.core.gen2.Phone")
-- The tag src/script/gen2/CallAsm.lua marks the pushed state with.
local CALLER_BOX = "gen2CallerBox"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-callerbox"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[callerbox] ok " .. label)
else
failures = failures + 1
print("[callerbox] FAIL " .. label .. " " .. tostring(detail))
end
end
-- Index of the caller box on the stack, or nil.
local function boxIndex()
for index, state in ipairs(game.stack.states or {}) do
if state[CALLER_BOX] then return index end
end
return nil
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
game.clock = { day = 0, hour = 9, minute = 0 }
save.phone = nil
assert(world:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31")
U.wait(3)
-- CheckStandingOnEntrance refuses a call on a door tile, so stand on floor.
local cx, cy = world.player.cellX, world.player.cellY
for y = 2, 16 do
for x = 2, 16 do
if world.map:cellCollision(x, y) == 0x00 then cx, cy = x, y end
end
end
assert(world:setMap("ROUTE_31", cx, cy, "down"), "no floor cell on ROUTE_31")
U.wait(3)
ok("no caller box before the phone rings", boxIndex() == nil, boxIndex())
Phone.addContact(save, 15) -- Joey, ROUTE_30, reachable from ROUTE_31
-- Wind the clock forward twenty in-game minutes at a time until
-- CheckReceiveCallTimer lands a random call.
local rang = false
for _ = 1, 40 do
game.clock.minute = game.clock.minute + 20
for _ = 1, 20 do
U.wait(1)
if world:busy() then break end
end
if world:busy() then rang = true break end
end
ok("a call rang in the overworld", rang)
if not rang then
print("FAIL gold_phone_caller_box (no call)")
love.event.quit(1)
return
end
-- The box goes up on the FIRST ring; the ring page only arrives after the
-- second pass, three Phone_Wait20Frames later (engine/phone/phone.asm:576),
-- so wait for the page rather than for a fixed count -- then let it type.
for _ = 1, 300 do
if #game.stack.states > 1 then break end
U.wait(1)
end
U.wait(40)
local ringIndex = boxIndex()
ok("the caller box is up during the ring", ringIndex ~= nil)
ok("and sits UNDER the ring's text page",
ringIndex ~= nil and ringIndex < #game.stack.states,
ringIndex and (ringIndex .. " of " .. #game.stack.states))
U.shot(game, out .. "/01-ring.png")
-- Into the caller's own script. One A gets past the ring page; the shot is
-- taken with a page of Joey's chatter up, which is what the player sees for
-- most of a call.
U.tap(game, "a")
U.wait(40)
ok("the box survives into the call itself", boxIndex() ~= nil)
U.shot(game, out .. "/02-talking.png")
-- Now page through to the end: the hang-up Click!, then the tail rows.
local finished = false
for _ = 1, 400 do
if not world:busy() and boxIndex() == nil then finished = true break end
U.tap(game, "a")
U.wait(4)
end
ok("the call ran to the end", finished, world:busy())
ok("and InitCallReceiveDelay took the caller box down",
boxIndex() == nil, boxIndex())
U.wait(10)
U.shot(game, out .. "/03-after.png")
print(failures == 0 and "PASS gold_phone_caller_box"
or ("FAIL gold_phone_caller_box (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+150
View File
@@ -0,0 +1,150 @@
-- The RING itself, in the running game: does the player actually HEAR the
-- phone before the hang-up beep?
--
-- Phone_StartRinging is `call WaitSFX` and only THEN `ld de, SFX_CALL /
-- call PlaySFX` (engine/phone/phone.asm:564-567), and RingTwice_StartCall
-- runs that whole pass twice (:458-469). Both halves matter in this port:
-- SFX_CALL is $6a (constants/sfx_constants.asm:109), low enough that the
-- PlaySFX priority gate DROPS it outright while a louder sound is still on
-- ch5-ch8, so a ring with no wait in front of it can be silent and leave
-- SFX_HANG_UP as the first phone sound the player ever hears.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_phone_ring.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-ring (default)
--
-- Listen for two rings about a second apart before the caller-ID page, then
-- the Click! at the end. The assertions cover the same ground for a run
-- nobody is listening to: the loud sound is started deliberately first, so a
-- ring that survives it proves the wait, not luck.
local U = require("tests.drivers.util")
local Phone = require("src.core.gen2.Phone")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-ring"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[ring] ok " .. label)
else
failures = failures + 1
print("[ring] FAIL " .. label .. " " .. tostring(detail))
end
end
-- Every gated sfx request, in order, with whether it actually started: a
-- dropped one returns no source (src/core/Sound.lua sfxPriorityGate).
local order, rings, sounded = {}, 0, 0
local realPlay = Sound.play
Sound.play = function(data, name)
local src = realPlay(data, name)
order[#order + 1] = tostring(name)
if tostring(name):find("Call") then
rings = rings + 1
if src then sounded = sounded + 1 end
end
return src
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
game.clock = { day = 0, hour = 9, minute = 0 }
save.phone = nil
assert(world:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31")
U.wait(3)
-- CheckStandingOnEntrance refuses a call on a door tile, so stand on floor.
local cx, cy = world.player.cellX, world.player.cellY
for y = 2, 16 do
for x = 2, 16 do
if world.map:cellCollision(x, y) == 0x00 then cx, cy = x, y end
end
end
assert(world:setMap("ROUTE_31", cx, cy, "down"), "no floor cell on ROUTE_31")
U.wait(3)
-- Is there an audio device at all? Without one every source is nil and
-- "the ring sounded" would fail for a reason that has nothing to do with
-- the phone, so the control decides whether that half is checked.
world:playSfxNamed("Sfx_ReadText2")
local audio = world.lastSfx ~= nil
print("[ring] audio device: " .. tostring(audio))
-- Diagnostic, not an assertion. WaitSFX blocks on the whole sfx channel
-- set (CheckSFX, home/audio.asm), but the VM's waitsfx hook only polls
-- World.lastSfx, so a sound started straight through Sound.play -- the
-- A-press beep src/render/TextBox.lua plays through the Press_AB alias --
-- is invisible to it while Sound.sfxBusy() can still see it. While these
-- two disagree, a ring queued inside that sound's window can still be
-- dropped by the priority gate.
world.lastSfx = nil -- isolate: the hook must answer about THIS sound alone
Sound.play(game.data, "Sfx_ReadText2")
local hook = world.vm and world.vm.waitSfxFn
print(("[ring] WaitSFX seam: hook says busy=%s, Sound.sfxBusy=%s")
:format(tostring(hook and not hook()), tostring(Sound.sfxBusy())))
U.wait(30)
Phone.addContact(save, 15) -- Joey, ROUTE_30, reachable from ROUTE_31
order, rings, sounded = {}, 0, 0
-- SFX_READ_TEXT_2 is $08: it outranks SFX_CALL, so this is the sound that
-- eats an unwaited ring. Started one frame before the call lands, exactly
-- as the A press that closes a textbox does.
world:playSfxNamed("Sfx_ReadText2")
local calls, shot = 0, false
for _ = 1, 40 do
if calls > 0 and not world:busy() then break end
game.clock.minute = game.clock.minute + 20
for _ = 1, 20 do
U.wait(1)
if world:busy() then break end
end
if world:busy() then
calls = calls + 1
for _ = 1, 400 do
if not shot and rings >= 2 then
-- The caller-ID page, with both rings already behind it. The wait
-- is for the typewriter: the page is only worth looking at once
-- the whole RING!…RING! line has printed.
U.wait(30)
U.shot(game, out .. "/01-ring.png")
shot = true
end
if not world:busy() then break end
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(2)
game.input.state.a = false
U.wait(2)
end
end
end
ok("a call rang in the overworld", calls > 0, calls)
-- RingTwice_StartCall's `call .Ring` plus its fallthrough.
ok("the phone rang twice", rings == 2, rings)
if audio then
ok("and both rings actually sounded past the priority gate",
sounded == rings, ("%d of %d"):format(sounded, rings))
end
local firstCall, firstHang
for index, name in ipairs(order) do
if not firstCall and name:find("Call") then firstCall = index end
if not firstHang and name:find("Hang") then firstHang = index end
end
ok("the ring is the FIRST phone sound, not the hang-up beep",
firstCall ~= nil and (firstHang == nil or firstCall < firstHang),
table.concat(order, ","))
Sound.play = realPlay
print(failures == 0 and "PASS gold_phone_ring"
or ("FAIL gold_phone_ring (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+159
View File
@@ -0,0 +1,159 @@
-- Gold's world-pipeline seam (World:drawPipeline), the Gen 2 peer of the
-- render_pipelines path src/world/OverworldController.lua:4867 gives Gen 1.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_pipeline_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-pipeline (default)
--
-- Registers a pipeline that paints an unmistakable magenta field, switches it
-- on, and asserts what the seam is supposed to guarantee: drawWorld owns the
-- frame, ctx carries the Gen 1 keys, ctx.drawFx anchors the standing FX,
-- worldPresent folds over the result, tilt is forced off, and a declined
-- frame falls back to the vanilla 2D draw instead of a blank screen.
local U = require("tests.drivers.util")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pipeline"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[pipeline] ok " .. label)
else
failures = failures + 1
print("[pipeline] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world:setMap("ROUTE_30", 10, 10, "down")
U.wait(10)
U.shot(game, out .. "/00-vanilla.png")
-- ---------------------------------------------------------------- record
local seen, canvas = {}, nil
local decline = false
local pipeline = {
label = "TESTPIPE",
levels = { "OFF", "ON" },
drawWorld = function(ctx)
seen.ctx = ctx
seen.drawWorld = (seen.drawWorld or 0) + 1
if decline then return nil end
local G = love.graphics
local w, h = ctx.width, ctx.height
if not canvas or canvas:getWidth() ~= w or canvas:getHeight() ~= h then
canvas = G.newCanvas(w, h)
end
local previous = G.getCanvas()
G.push("all")
G.origin()
G.setCanvas(canvas)
G.clear(0.8, 0.1, 0.6, 1)
G.setColor(1, 1, 1, 1)
for x = 0, w, 32 do G.rectangle("fill", x, 0, 1, h) end
for y = 0, h, 32 do G.rectangle("fill", 0, y, w, 1) end
-- the standing FX, anchored under this pipeline's own (identity) camera
ctx.drawFx(function(wx, wy)
return (wx - ctx.cam.x) * ctx.scale, (wy - ctx.cam.y) * ctx.scale
end, ctx.scale)
G.setCanvas(previous)
G.pop()
seen.drew = true
return canvas
end,
worldPresent = function(image, ctx)
seen.worldPresent = (seen.worldPresent or 0) + 1
seen.presentCtx = ctx
return image
end,
}
-- A fresh table so Pipelines.list()'s identity-keyed memo re-sorts; the
-- mod merge hands it a new one for the same reason.
game.data.render_pipelines = { testpipe = pipeline }
Pipelines.install(game.data)
ok("registered", Pipelines.get("testpipe") ~= nil, "not in the registry")
-- ------------------------------------------------------------ switched on
Tilt.setLevel(1)
Pipelines.setLevel("testpipe", 1)
ok("tilt forced off", Tilt.level == 0, "tilt still " .. tostring(Tilt.level))
ok("world pipeline claimed", Pipelines.worldPipeline() == "testpipe",
tostring(Pipelines.worldPipeline()))
U.wait(5)
U.shot(game, out .. "/01-pipeline-on.png")
ok("drawWorld ran", (seen.drawWorld or 0) > 0, "never called")
ok("drawWorld drew", seen.drew == true, "declined every frame")
ok("worldPresent ran", (seen.worldPresent or 0) > 0, "never called")
local ctx = seen.ctx
ok("ctx.state is the world", ctx and ctx.state == world, "wrong state")
ok("ctx.cam is the camera", ctx and ctx.cam == world.camera, "wrong camera")
ok("ctx.scale is zoomScale", ctx and ctx.scale == world:zoomScale(),
ctx and tostring(ctx.scale))
ok("ctx.bgY is the camera row", ctx and ctx.bgY == world.camera.y,
ctx and tostring(ctx.bgY))
ok("ctx.vw/vh are the view", ctx and ctx.vw == world.viewW
and ctx.vh == world.viewH, ctx and tostring(ctx.vw))
ok("ctx.level is the ladder", ctx and ctx.level == 1, ctx and tostring(ctx.level))
ok("ctx.width/height are the window",
ctx and ctx.width == love.graphics.getWidth()
and ctx.height == love.graphics.getHeight(), "mismatch")
ok("ctx.paletteFor is nil-valued (art is baked)",
ctx and ctx.paletteFor and ctx.paletteFor(world.map) == nil, "returned colours")
ok("ctx.spriteColors is nil-valued",
ctx and ctx.spriteColors and ctx.spriteColors() == nil, "returned colours")
ok("ctx.fx has Gold's two effects",
ctx and ctx.fx and type(ctx.fx.emote) == "function"
and type(ctx.fx.heal) == "function", "missing fx")
ok("ctx.drawFx is callable", ctx and type(ctx.drawFx) == "function", "missing")
ok("worldPresent got the same ctx", seen.presentCtx == seen.ctx, "different ctx")
-- ------------------------------------------------- the FX composite path
-- An emote over the player exercises ctx.drawFx end to end: it must be the
-- pipeline that composites it, and the vanilla drawPeople must not also.
local sheet
for _, img in pairs(world.emoteImages or {}) do sheet = img break end
if sheet then
world.emote = { image = sheet, entity = world.player, left = 240 }
end
U.wait(4)
ok("emote is up", world.emote ~= nil, "no emote sheet loaded")
local fxOk = pcall(function()
-- the same call the pipeline made, run again outside the guard so a throw
-- surfaces here rather than only retiring the pipeline
seen.ctx.drawFx(function(wx, wy) return wx, wy end, 1)
end)
ok("drawFx composites without throwing", fxOk, "threw")
U.shot(game, out .. "/02-pipeline-emote.png")
-- ----------------------------------------------------- a declined frame
decline = true
U.wait(5)
U.shot(game, out .. "/03-pipeline-declined.png")
ok("declined frames still call drawWorld", (seen.drawWorld or 0) > 1, "stopped")
decline = false
-- ------------------------------------------------------------ switched off
Pipelines.setLevel("testpipe", 0)
local before = seen.drawWorld
U.wait(5)
ok("off means not called", seen.drawWorld == before,
"still drawing at level 0")
U.shot(game, out .. "/04-pipeline-off.png")
if failures == 0 then
print("[pipeline] PASS")
else
print("[pipeline] FAILURES: " .. failures)
end
love.event.quit(failures == 0 and 0 or 1)
end
+69
View File
@@ -0,0 +1,69 @@
-- Assertion driver: the shared POKECENTER_2F staircase, walked for real.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_pokecenter_stairs.lua love .
--
-- tests/gen2_pokecenter_stairs_test.lua drives takeWarp against the real map
-- defs; what it cannot do is put a player's feet on the tile. This walks up
-- the stairs of two different Pokemon Centers and back down, through the real
-- step loop, the real fades and the real warp machinery, and asserts the one
-- thing the cart guarantees: the single second floor leads back down into
-- whichever centre it was climbed from (home/map.asm CopyWarpData's -1 arm).
-- It PASSES or it errors; there is nothing to eyeball.
local U = require("tests.drivers.util")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Wait out fades, script beats and the step in flight.
local function settle(limit)
for _ = 1, limit or 600 do
if not world:busy() and not (world.player and world.player.moving) then
return
end
U.wait(1)
end
error("world never settled")
end
local function at()
return world.map.id, world.player.cellX, world.player.cellY
end
local function climbAndReturn(centerId)
-- Stand two cells east of the staircase and walk onto it.
assert(world:setMap(centerId, 2, 7, "left"), "setMap " .. centerId)
U.wait(5)
settle()
U.hold(game, "left", 80)
settle()
local mapId, x, y = at()
assert(mapId == "POKECENTER_2F",
("%s stairs went to %s at (%d,%d), not the shared 2F")
:format(centerId, mapId, x, y))
U.log(centerId .. ": up the stairs onto the shared 2F")
-- Step off the staircase, then back onto it: the -1 warp must resolve to
-- the centre just left.
U.hold(game, "right", 30)
settle()
assert(world.player.cellX >= 1,
"did not step off the 2F staircase (x=" .. world.player.cellX .. ")")
U.hold(game, "left", 80)
settle()
mapId, x, y = at()
assert(mapId == centerId,
("the 2F stairs came down in %s, expected %s"):format(mapId, centerId))
assert(x == 0 and y == 7,
("landed at (%d,%d), expected the 1F staircase (0,7)"):format(x, y))
U.log(centerId .. ": back down into the same centre")
end
climbAndReturn("CHERRYGROVE_POKECENTER_1F")
climbAndReturn("VIOLET_POKECENTER_1F")
U.log("PASS gold_pokecenter_stairs")
love.event.quit()
end
@@ -0,0 +1,69 @@
-- Eyeball driver: the paper a textbox sits on while the Pokegear holds the
-- screen. A call's text box is a plain src/render/TextBox.lua state pushed
-- OVER the gear, and the gear's own box (Pokegear:textbox) already lays the
-- card's cream paper down first, because every tile the box is built from is
-- font-page ($79-$7e frame, ' ' $7f interior) and TownMapPals hands every tile
-- id >= $60 to BG palette 0, whose colour 0 is `RGB 28, 31, 20`
-- (pokegold engine/pokegear/pokegear.asm TownMapPals, gfx/pokegear/pokegear.pal).
-- The two shots below have to agree: if the second one shows a pure white band
-- across the bottom of the card where the first shows cream, the pushed box is
-- still hard-filling white.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_SHOTS=/tmp/gearpaper \
-- POKEPORT_DRIVER=tests/drivers/gold_pokegear_call_paper.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local U = require("tests.drivers.util")
local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/gearpaper"
return function(game)
local function tap(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 4)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
-- Joey (contact 15) is the shortest reachable call: he lives on ROUTE_30, so
-- his number is dialable from ROUTE_31 and his SCRIPT1 talks straight away.
local Phone = require("src.core.gen2.Phone")
assert(w:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31")
U.wait(3)
Phone.addContact(game.save, 15)
w:setEngineFlag(2, true) -- ENGINE_PHONE_CARD
w:setEngineFlag(4, true) -- ENGINE_POKEGEAR
game:openStartMenuItem("pokegear")
U.wait(3)
local gear = game.stack:top()
assert(gear and gear.cards, "the Pokegear did not open")
gear.mode = "card"
for index, card in ipairs(gear.cards) do
if card.id == "phone" then gear.cardIndex = index end
end
U.wait(3)
-- Reference: the gear's OWN box, drawn through Pokegear:textbox. Its
-- interior is the cream paper, and it is the colour the call box must match.
U.shot(game, SHOTS .. "/01-gear-own-box.png")
tap("a", 3) -- CALL / DELETE / CANCEL on Joey's slot
tap("a", 3) -- CALL
-- The first page of the call, i.e. a pushed TextBox over the card.
for _ = 1, 240 do
local top = game.stack and game.stack:top()
if top and top.pages and top ~= gear then break end
U.wait(1)
end
U.shot(game, SHOTS .. "/02-call-textbox.png")
U.log("compare 01 and 02: the band behind the call text must be the same",
"cream as the gear's own box, not white")
love.event.quit(0)
end
+141
View File
@@ -0,0 +1,141 @@
-- Assertion driver: the Pokegear radio's song outlives the gear, and the
-- Vermilion Snorlax hears it.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_radio_persist.lua love .
--
-- The chain under test, all through the real screens: engine flags written by
-- World:setEngineFlag (the store every granting script's `setflag` lands in)
-- unlock the START menu's POKeGEAR row and the gear's cards; the radio card
-- tunes 20.0 to the POKe FLUTE channel (Kanto + ENGINE_EXPN_CARD); closing
-- the gear leaves the song playing as the map music
-- (ExitPokegearRadio_HandleMusic / RadioMusicRestartDE); and `special
-- SnorlaxAwake` then reads that very song and starts the BATTLETYPE_FORCEITEM
-- Snorlax fight.
local U = require("tests.drivers.util")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local Music = require("src.core.Music")
-- A party to fight with; the unlock flags go through the same store every
-- granting script's `setflag` writes.
local save = game.save
save.party = { {
species = "TYPHLOSION", name = "TYPHLOSION", nickname = "TYPHLOSION",
level = 60, hp = 180, maxHp = 180,
moves = { { id = "FLAMETHROWER", pp = 15, maxPp = 15 } },
} }
for _, flag in ipairs({ 0, 1, 2, 3, 4, 11 }) do
world:setEngineFlag(flag, true)
end
-- Beside the sleeping Snorlax: (33,8) is one of SnorlaxAwake's own
-- .ProximityCoords, and facing right reaches the doll's object cell (34,8).
assert(world:setMap("VERMILION_CITY", 33, 8, "right"),
"setMap failed for VERMILION_CITY")
U.wait(10)
local function top() return game.stack:top() end
local function topIs(id)
local t = top()
return t and t.screenId == id and t or nil
end
-- START -> the menu, with the POKeGEAR row unlocked by the flags alone.
U.tap(game, "start")
local menu
for _ = 1, 60 do
menu = topIs("Gen2StartMenu")
if menu then break end
U.wait(1)
end
assert(menu, "START did not open the start menu")
local ids = {}
for _, item in ipairs(menu.items) do ids[item.value] = true end
assert(ids.pokegear, "the POKeGEAR row is missing from the START menu")
assert(ids.pokedex, "the POKeDEX row is missing from the START menu")
U.log("START menu shows POKeDEX and POKeGEAR from the engine flags")
-- Down to the POKeGEAR row (POKeDEX, POKeMON, PACK, POKeGEAR) and in.
for _ = 1, 3 do U.tap(game, "down") U.wait(2) end
U.tap(game, "a")
local gear
for _ = 1, 60 do
gear = topIs("Gen2Pokegear")
if gear then break end
U.wait(1)
end
assert(gear, "the POKeGEAR row did not open the gear")
assert(#gear.cards == 4, "expected all four cards, got " .. #gear.cards)
-- Strip: CLOCK, MAP, RADIO, PHONE. Two rights and A is the radio card.
U.tap(game, "right") U.wait(2)
U.tap(game, "right") U.wait(2)
U.tap(game, "a") U.wait(2)
assert(gear.mode == "card" and gear:card().id == "radio",
"did not land on the radio card")
-- Wind the knob to 20.0: RADIO_CHANNELS row 7, six UPs from row 1.
for _ = 1, 6 do U.tap(game, "up") U.wait(2) end
assert(gear.radioShow == "POKE_FLUTE_RADIO",
"20.0 did not resolve the POKe FLUTE channel (got "
.. tostring(gear.radioShow) .. ")")
for _ = 1, 60 do
if Music.current() == "Music_PokeFluteChannel" then break end
U.wait(1)
end
assert(Music.current() == "Music_PokeFluteChannel",
"the POKe FLUTE channel is not playing")
U.log("tuned 20.0: the POKe FLUTE channel is playing")
-- B off the card, B out of the gear, B out of the menu: the song must
-- survive all three (ExitPokegearRadio_HandleMusic keeps a tuned song).
U.tap(game, "b") U.wait(3)
U.tap(game, "b") U.wait(3)
for _ = 1, 60 do
if not topIs("Gen2StartMenu") then break end
U.tap(game, "b")
U.wait(2)
end
U.wait(5)
assert(Music.current() == "Music_PokeFluteChannel",
"the song did not survive closing the gear (playing "
.. tostring(Music.current()) .. ")")
assert(Music.mapSong() == "Music_PokeFluteChannel",
"the song did not become the map music")
U.log("gear closed: the POKe FLUTE channel persists as the map music")
-- A on the Snorlax. SnorlaxAwake hears the flute channel, and the script
-- runs on into `loadwildmon SNORLAX, 50` and `startbattle`.
U.tap(game, "a")
local battle
for _ = 1, 300 do
battle = topIs("Gen2BattleTransition") or topIs("Gen2BattleState")
if battle then break end
U.tap(game, "a")
U.wait(3)
end
assert(battle, "the Snorlax did not wake: no battle started")
-- Ride the wipe into the battle screen and read the enemy off it.
local state
for _ = 1, 600 do
state = topIs("Gen2BattleState")
if state then break end
U.wait(1)
end
assert(state, "the transition never handed over to the battle screen")
local enemy = state.battle and state.battle.enemy
assert(enemy and enemy.species == "SNORLAX",
"expected SNORLAX, got " .. tostring(enemy and enemy.species))
assert(enemy.level == 50, "expected L50, got " .. tostring(enemy.level))
-- BATTLETYPE_FORCEITEM: InitEnemyMon hands Item1 over unconditionally.
assert(enemy.item ~= nil, "the forced held item is missing")
U.log(("SNORLAX woke up: L%d battle fired, holding %s")
:format(enemy.level, tostring(enemy.item)))
print("[driver] PASS gold radio persistence + Snorlax wake")
love.event.quit()
end
+210
View File
@@ -0,0 +1,210 @@
-- Assertion driver: the three legendary beasts, in the running game.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_roamers.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-roamers (default)
--
-- The whole feature was a model with no callers: `special InitRoamMons` wrote
-- the three structs and nothing ever moved them, rolled for them or banked
-- them. tests/gen2_roamers_test.lua pins the four call sites against fixtures;
-- this walks the real thing -- the real special, real map loads on real Johto
-- routes, and a real battle screen with a real beast on it.
local U = require("tests.drivers.util")
local Roamers = require("src.core.gen2.Roamers")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-roamers"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
save.roamers = nil
-- ---- InitRoamMons, through the special the Burned Tower runs ------------
-- Dispatched BY NAME through the cache's own specialOrder, so this is the
-- same path BurnedTowerB1F's `special InitRoamMons` takes.
local order = world.constants and world.constants.specialOrder
assert(order, "no specialOrder in the cache")
local id
for index, name in ipairs(order) do
if name == "InitRoamMons" then id = index - 1 break end
end
assert(id, "InitRoamMons is not in SpecialsPointers")
world.vm:runSpecial(id)
local beasts = Roamers.list(save)
assert(beasts and #beasts == 3,
"InitRoamMons did not put three beasts on the save")
local starts = {}
for i, slot in ipairs(beasts) do
assert(Roamers.active(slot), "beast " .. i .. " came out inactive")
assert(slot.hp == 0, "a fresh beast has no rolled stats yet")
starts[i] = slot.map
U.log(("beast %d: %s L%d on %s"):format(i, slot.species, slot.level, slot.map))
end
assert(starts[1] == "ROUTE_42" and starts[2] == "ROUTE_37"
and starts[3] == "ROUTE_38",
"the three starting routes are not Raikou 42 / Entei 37 / Suicune 38")
-- ---- UpdateRoamMons, on a real door warp --------------------------------
-- MapSetupScript_Fall drops into _Door drops into _Train, and UpdateRoamMons
-- is _Train's tail -- so walking through a door nudges every beast one
-- connection along.
local before = { beasts[1].map, beasts[2].map, beasts[3].map }
local moves = 0
for _ = 1, 12 do
local was = { beasts[1].map, beasts[2].map, beasts[3].map }
world:runMapSetup(0xf5, function() -- MAPSETUP_DOOR
return world:setMap("ROUTE_29", 20, 8, "down")
end)
for _ = 1, 60 do
if not world.mapSetup then break end
U.wait(1)
end
for i = 1, 3 do
if beasts[i].map ~= was[i] then moves = moves + 1 end
end
end
assert(moves > 0,
"twelve door warps and not one beast moved: UpdateRoamMons is not wired")
local anyMoved = false
for i = 1, 3 do
if beasts[i].map ~= before[i] then anyMoved = true end
assert(Roamers.entryFor(beasts[i].map, world.encounters),
("beast %d walked off the roam map list onto %s")
:format(i, tostring(beasts[i].map)))
end
assert(anyMoved, "the beasts ended exactly where they started")
U.log(("UpdateRoamMons: %d moves over twelve door warps, now on %s / %s / %s")
:format(moves, beasts[1].map, beasts[2].map, beasts[3].map))
-- A plain warp names neither command, so nothing may move.
local held = { beasts[1].map, beasts[2].map, beasts[3].map }
world:runMapSetup(0xf1, function() -- MAPSETUP_WARP
return world:setMap("ROUTE_30", 10, 10, "down")
end)
for _ = 1, 60 do
if not world.mapSetup then break end
U.wait(1)
end
for i = 1, 3 do
assert(beasts[i].map == held[i],
"a plain warp moved a beast; only _Connection / _Train / _Teleport may")
end
U.log("and a plain warp leaves them alone, the way MapSetupScript_Warp does")
-- ---- JumpRoamMons, on a teleport ----------------------------------------
-- Flying is MAPSETUP_TELEPORT, whose third row scatters every beast to a
-- random roam map. Over ten flights all three have to land somewhere new.
local seen = { {}, {}, {} }
for _ = 1, 10 do
-- JumpRoamMons runs ABOVE the load, so "the player's map" it re-rolls off
-- is the one being LEFT, not the destination.
local leaving = world.map.id
world:runMapSetup(0xf4, function() -- MAPSETUP_TELEPORT
return world:setMap("ROUTE_29", 20, 8, "down")
end)
for _ = 1, 60 do
if not world.mapSetup then break end
U.wait(1)
end
for i = 1, 3 do
seen[i][beasts[i].map] = true
assert(beasts[i].map ~= leaving,
("JumpRoamMon dropped beast %d on %s, the map the player just left")
:format(i, tostring(leaving)))
end
end
for i = 1, 3 do
local count = 0
for _ in pairs(seen[i]) do count = count + 1 end
assert(count > 1,
("beast %d sat on one map across ten teleports: JumpRoamMons is dead")
:format(i))
end
U.log("JumpRoamMons: ten flights scattered all three, never onto the map left")
-- ---- CheckEncounterRoamMon, into a real battle --------------------------
-- Put Raikou under the player's feet and pin the roll to the one byte that
-- gets past both of CheckEncounterRoamMon's gates and picks slot 1.
local Mon = require("src.battle.gen2.Mon")
save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
assert(save.party[1], "could not build the player's mon")
world:setMap("ROUTE_29", 20, 8, "down")
U.wait(10)
beasts[1].map = "ROUTE_29"
world.roamerRandom = function() return 1 end
world.player.cellX, world.player.cellY = 20, 8
-- CanEncounterWildMon has to pass before ChooseWildEncounter is reached at
-- all, so stand in real tall grass rather than wherever the warp landed.
-- The cell is found in the map rather than remembered.
local FieldMoves = require("src.world.gen2.FieldMoves")
local grassX, grassY
for cy = 0, world.map.heightCells - 1 do
for cx = 0, world.map.widthCells - 1 do
local coll = world.map:cellCollision(cx, cy)
if FieldMoves.canEncounterWildMon(
world.map.def.environment, coll, false) then
grassX, grassY = cx, cy
break
end
end
if grassX then break end
end
assert(grassX, "no encounter tile anywhere on ROUTE_29")
world.player.cellX, world.player.cellY = grassX, grassY
world.player.px, world.player.py = grassX * 16, grassY * 16
U.log(("standing in Route 29's grass at (%d,%d)"):format(grassX, grassY))
-- TryWildEncounter runs `.EncounterRate` FIRST and only reaches
-- ChooseWildEncounter -- CheckEncounterRoamMon included -- on a pass
-- (engine/overworld/wildmons.asm), so the beast sits behind a random byte
-- this driver does not own. Pin THAT byte rather than the roamer roll: the
-- map's own rate is still read and both arms of the gate are asserted, which
-- is the cart order itself rather than a way around it.
local Encounter = require("src.battle.gen2.Encounter")
local realTriggers = Encounter.triggers
local rate = Encounter.grassRate(world:wildTables(), world.map.id,
world.daytime)
assert(rate and rate > 0, "ROUTE_29 has no grass encounter rate to gate on")
Encounter.triggers = function() return false end
local gated = world:tryWildEncounter()
Encounter.triggers = function(r) return realTriggers(r, function() return 0 end) end
local ok, met = pcall(world.tryWildEncounter, world)
Encounter.triggers = realTriggers
assert(ok, met)
assert(not gated,
"a beast turned up with the encounter rate refusing: the roamer check is "
.. "above `.EncounterRate` rather than inside ChooseWildEncounter")
assert(met,
"the wild roll met nothing with a beast on the player's own route")
U.log(("ROUTE_29 grass rate %d/256; the beast is behind it, not beside it")
:format(rate))
local battle
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 roaming battle never reached the screen")
U.wait(90)
assert(battle.battle.roaming == 1,
"the battle does not know it is BATTLETYPE_ROAMING")
assert(battle.battle.enemy.species == "RAIKOU",
"met " .. tostring(battle.battle.enemy.species) .. " rather than RAIKOU")
assert(battle.battle.enemy.level == 40, "at the wrong level")
assert(beasts[1].hp > 0,
".InitRoamHP banks the beast's full HP on the FIRST meeting")
assert(beasts[1].dvs, "and rolls its DVs once, so it stays one individual")
assert(U.shot(game, out .. "/00-raikou.png"), "no screenshot")
U.log(("CheckEncounterRoamMon: met %s L%d, slot %d, %d HP banked, DVs kept")
:format(battle.battle.enemy.species, battle.battle.enemy.level,
battle.battle.roaming, beasts[1].hp))
U.log("PASS gold_roamers in " .. out)
love.event.quit()
end
+81
View File
@@ -0,0 +1,81 @@
-- Rock-smash probe: resume a checkpoint, stand at Burned Tower 1F (4,4),
-- press A at the rock on (4,3), and report every state change -- the textbox
-- body, the stack top, and whether the rock object is still there.
--
-- POKEPORT_IDENTITY=gold-v2 POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=07 \
-- POKEPORT_DRIVER=tests/drivers/gold_rock_probe.lua love .
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local resume = os.getenv("POKEPORT_GOLD_RESUME")
if resume then
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[rock] cannot resume %s: %s"):format(resume, tostring(err)))
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
end
local world = game.world
-- Skip the rival ambush: hide his object and advance the scene the way his
-- own script would have, so the probe measures the ROCK and nothing else.
world.events:set(1733, true) -- EVENT_RIVAL_BURNED_TOWER
world.mapScenes["BURNED_TOWER_1F"] = 1 -- SCENE_BURNEDTOWER1F_FIREBREATHER_DICK
world:setMap("BURNED_TOWER_1F", 9, 15, "up")
bot:wait(30)
bot:clearDialogue(nil, 12000)
print("[rock] after rival: map=" .. tostring(A.mapId(game)))
if A.mapId(game) ~= "BURNED_TOWER_1F" then
print("[rock] ABORT: rival fight lost / left the tower")
love.event.quit()
return
end
local function report(tag)
local npc = A.npcAt(game, 4, 3)
print(("[rock] %-12s map=%s pos=%s,%s rock=%s busy=%s")
:format(tag, tostring(A.mapId(game)),
tostring(select(1, A.pos(game))), tostring(select(2, A.pos(game))),
tostring(npc ~= nil), tostring(A.busyReason(game))))
end
-- Simulate a long session: a stale hLastTalked from an earlier talk. The
-- cart overwrites it on every A-press dispatch; a port that does not will
-- smash the wrong object.
world.vm.lastTalked = 9
report("arrived")
local okw = bot:walkTo(4, 4)
print("[rock] walkTo(4,4):", tostring(okw))
report("standing")
bot:face("up")
for attempt = 1, 4 do
bot:tap("a")
bot:wait(4)
for _ = 1, 30 do
if A.busy(game) then break end
bot:wait(1)
end
if A.busy(game) then
print("[rock] tap " .. attempt .. " opened something")
break
end
print("[rock] tap " .. attempt .. " opened nothing")
end
bot:clearDialogue({ "yes" }, 4000)
report("talked")
love.event.quit()
end
+159
View File
@@ -0,0 +1,159 @@
-- ROCK SMASH's wild encounter, and the beasts scattering on CONTINUE, driven
-- through the real game.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=13 \
-- POKEPORT_DRIVER=tests/drivers/gold_rock_smash_probe.lua love .
--
-- Two things a ROM-free suite cannot see, because both live behind World:load:
--
-- * the `readMem` hook. RockSmashScript is `callasm RockMonEncounter /
-- readmem wTempWildMonSpecies / iffalse .done / randomwildmon /
-- startbattle`, and the byte only reads back if World:load installed the
-- seam -- otherwise the VM answers out of its own sparse store, sees 0 and
-- skips the battle, which is what the port did for every smash in the game.
-- * `farcall JumpRoamMons` on the continue path
-- (engine/menus/intro_menu.asm), which is World:roamMonsOnContinue and runs
-- from inside World:load rather than from any map setup script.
--
-- Prints one PASS/FAIL line per claim and quits, so a killed run is visibly
-- incomplete rather than silently green.
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
-- data/wild/treemon_maps.asm RockMonMaps: the rock at (16,14) of Dark Cave's
-- Violet entrance is object 2, reachable from (15,14).
local ROCK_MAP = "DARK_CAVE_VIOLET_ENTRANCE"
local ROCK_X, ROCK_Y = 16, 14
local STAND_X, STAND_Y = 15, 14
-- constants/pokemon_constants.asm; TreeMonSet_Rock is 90 KRABBY / 10 SHUCKLE.
local ROCK_SPECIES = { [98] = "KRABBY", [213] = "SHUCKLE" }
local results = {}
local function claim(ok, text)
results[#results + 1] = ok and true or false
print((ok and "[rocksmash] PASS " or "[rocksmash] FAIL ") .. text)
end
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local resume = os.getenv("POKEPORT_GOLD_RESUME") or "13"
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[rocksmash] cannot resume %s: %s"):format(resume, tostring(err)))
love.event.quit()
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
-- ---- JumpRoamMons on CONTINUE -------------------------------------------
--
-- Park all three beasts on one map, then load the very same save again the
-- way the CONTINUE menu does. JumpRoamMon re-rolls off the player's own map
-- and picks one of sixteen otherwise, so all three staying put is a 1 in
-- 4096 coincidence rather than a passing implementation.
local save = game.save
if save and not save.roamers then
-- `special InitRoamMons`, which the Burned Tower basement runs when the
-- floor gives way. A checkpoint taken before that has no structs, and the
-- scatter below is about the CONTINUE path rather than about this.
require("src.core.gen2.Roamers").init(save, { force = true })
print("[rocksmash] note: seeded InitRoamMons for this checkpoint")
end
if not (save and save.roamers) then
claim(false, "the save has roamers to scatter")
else
for _, slot in ipairs(save.roamers) do
if slot.species then slot.map = "ROUTE_29" end
end
local continued = pcall(game.continueGame, game, save)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local moved = 0
for _, slot in ipairs(game.save.roamers or {}) do
if slot.species and slot.map ~= "ROUTE_29" then moved = moved + 1 end
end
claim(continued and moved > 0,
("CONTINUE scattered the beasts (%d of 3 left ROUTE_29)"):format(moved))
end
-- ---- RockMonEncounter ----------------------------------------------------
local world = game.world
world:setMap(ROCK_MAP, STAND_X, STAND_Y, "right")
bot:wait(30)
bot:clearDialogue(nil, 4000)
claim(A.mapId(game) == ROCK_MAP, "arrived at " .. ROCK_MAP)
-- HasRockSmash is CheckPartyMove: the lead has to know the move for
-- AskRockSmashScript to open at all. Teaching it is the setup, not the
-- thing under test.
local lead = game.save.party and game.save.party[1]
if lead then
lead.moves = lead.moves or {}
lead.moves[#lead.moves + 1] = { id = "ROCK_SMASH", pp = 15, maxPp = 15 }
end
claim(lead ~= nil, "the lead can be taught ROCK SMASH")
-- `ld a, 10 / RandomRange / cp 4` and then SelectTreeMon's 0..99: a zero
-- passes the 40 percent and lands in the 90 percent KRABBY bracket, so the
-- smash below is the deterministic case.
world.rockmonRandom = function() return 0 end
-- Dark Cave is a wild-encounter map; the walk to the rock must not be
-- interrupted by one, and the battle under test comes from the script.
world.noWildEncounters = true
-- The `startbattle` RockSmashScript ends on comes through here, and the bot
-- fights the battle out before any poll of its own could see it -- so the
-- species is read off the seam the script itself reaches.
local fought
local realScripted = world.startScriptedBattle
world.startScriptedBattle = function(self, record, wild, onDone)
if wild and wild.species then fought = wild.species end
return realScripted(self, record, wild, onDone)
end
local reached = bot:approachAndFace(ROCK_X, ROCK_Y)
claim(reached, "faced the rock at " .. ROCK_X .. "," .. ROCK_Y)
for attempt = 1, 4 do
bot:tap("a")
bot:wait(4)
for _ = 1, 40 do
if A.busy(game) then break end
bot:wait(1)
end
if A.busy(game) then break end
print("[rocksmash] tap " .. attempt .. " opened nothing")
end
-- AskRockSmashScript's yesorno, then the smash, the earthquake and the roll.
bot:clearDialogue({ "yes" }, 8000)
for _ = 1, 600 do
if fought or not A.busy(game) then break end
bot:wait(1)
end
claim(fought ~= nil, "the smash reached startbattle at all")
claim(fought ~= nil and ROCK_SPECIES[fought] ~= nil,
"and the wild mon came out of TREEMON_SET_ROCK (got "
.. tostring(fought and ROCK_SPECIES[fought] or fought) .. ")")
local failures = 0
for _, value in ipairs(results) do
if not value then failures = failures + 1 end
end
print(("[rocksmash] %d claims, %d failed"):format(#results, failures))
love.event.quit()
end
+107
View File
@@ -0,0 +1,107 @@
-- SecurityCamera1a (maps/TeamRocketBaseB1F.asm:22): the two Rocket grunts the
-- camera calls down on you, ONE AFTER ANOTHER.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_rocket_cameras.lua love .
--
-- The cart has exactly ONE object for both of them (TEAMROCKETBASEB1F_ROCKET1)
-- and stages it twice: `moveobject` back to the corridor mouth, `appear`,
-- `applymovement SecurityCameraMovement1`, battle, `disappear` -- then the same
-- five commands again for the second grunt. So the second run MUST start from
-- the cell the second `moveobject` names (19,2) and not from wherever the first
-- grunt stopped, or he sprints on past the player and off the room.
--
-- The driver prints where the object stands at the start and the end of each
-- of the two approach walks and shoots both. Shots land in /tmp/gold-cameras.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local ROCKET1 = 1 -- def.objects index; the object const is this + 1
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-cameras"
local function tap(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 4)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local starter = Mon.new(game.data, "TYPHLOSION", 60)
assert(starter, "could not build a TYPHLOSION")
game.save.party = { starter }
world:setMap("TEAM_ROCKET_BASE_B1F", 23, 2, "right")
U.wait(20)
assert(world.map.id == "TEAM_ROCKET_BASE_B1F", tostring(world.map.id))
-- Both guards the coord event checks: EVENT_SECURITY_CAMERA_1 (already seen)
-- and EVENT_TEAM_ROCKET_BASE_POPULATION (the base already cleared out). The
-- second is the flag the two standing trainers on this floor carry, read off
-- the object rather than named as a number.
local rocket = world.map.def.objects[ROCKET1]
world.events:set(world.map.def.objects[2].eventFlag, false)
for _, ev in ipairs(world.map.def.coordEvents or {}) do
if ev.x == 24 and ev.y == 2 then world.cameraEvent = ev end
end
local runs = {}
local realBegin = world.beginMovement
world.beginMovement = function(self, objectId, bytes, onDone)
if objectId == ROCKET1 + 1 then
local ent = self:objectEntity(objectId)
runs[#runs + 1] = {
fromX = ent and ent.cellX, fromY = ent and ent.cellY, bytes = #(bytes or {}),
}
end
return realBegin(self, objectId, bytes, onDone)
end
U.shot(game, out .. "/00-corridor.png")
U.hold(game, "right", 20)
U.wait(10)
local shots = 0
local battles = 0
for _ = 1, 2000 do
local top = game.stack:top()
if top and top.battle then
battles = battles + 1
U.shot(game, ("%s/%02d-battle.png"):format(out, battles))
for _ = 1, 900 do
if top.battle.over then break end
tap("a", 3)
end
U.wait(20)
end
if #runs > shots then
shots = #runs
U.wait(30)
U.shot(game, ("%s/%02d-approach.png"):format(out, shots))
end
if not world:busy() and battles >= 2 then break end
tap("a", 2)
end
for i, run in ipairs(runs) do
print(("[driver] approach %d started at (%s,%s), %d movement bytes")
:format(i, tostring(run.fromX), tostring(run.fromY), run.bytes))
end
U.wait(20)
U.shot(game, out .. "/09-after.png")
assert(#runs >= 2, ("only %d approach walks ran; the camera calls two grunts")
:format(#runs))
for i, run in ipairs(runs) do
assert(run.fromX == 19 and run.fromY == 2,
("approach %d started at (%s,%s); every `moveobject` in SecurityCamera1a "
.. "names (19,2)"):format(i, tostring(run.fromX), tostring(run.fromY)))
end
print("[driver] PASS gold rocket security cameras in " .. out)
love.event.quit()
end
+80
View File
@@ -0,0 +1,80 @@
-- The Route 29 DUDE, the scene two separate defects meet in.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_route29_tutorial.lua love .
--
-- What a human is watching for:
-- * "Would you like me / to show you how to / catch #MON?" -- the YES/NO
-- prompt must go up OVER that box, with the question still on it and with
-- NO extra button press in between. CatchingTutorialIntroText ends
-- `done`, so DoneText returns without a PromptButton (home/text.asm:484)
-- and Script_yesorno's `call YesNoBox` is the very next thing that
-- happens (engine/overworld/scripting.asm:366).
-- * when the tutorial battle ends, the Route 29 map theme comes back. The
-- silence used to outlive the battle: wDontPlayMapMusicOnReload was read
-- at the end of the fight instead of at the reload behind it, so the flag
-- sat set and stopped the music at the END of the NEXT battle.
--
-- Shots land in /tmp/gold-route29.
local U = require("tests.drivers.util")
local Music = require("src.core.Music")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-route29"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Route29Tutorial1 is a coord event at (53,8) gated on
-- SCENE_ROUTE29_CATCH_TUTORIAL (maps/Route29.asm:422); take the scene id
-- from the map rather than hardcoding the constant.
world:setMap("ROUTE_29", 53, 10, "up")
U.wait(20)
local trigger
for _, ev in ipairs(world.map.def.coordEvents or {}) do
if ev.x == 53 and (ev.y == 8 or ev.y == 9) then trigger = trigger or ev end
end
assert(trigger, "ROUTE_29 has no catch tutorial coord event")
world.mapScenes[world.map.id] = trigger.sceneId or 0
local mapSong = Music.mapSong()
U.hold(game, "up", 24)
U.wait(20)
-- Page to the question, counting the presses it costs. The prompt must
-- arrive on the press that finishes the last page, not one press later.
local presses, sawPrompt = 0, false
for _ = 1, 120 do
if world.choicebox then
sawPrompt = true
break
end
U.tap(game, "a")
presses = presses + 1
U.wait(12)
end
U.shot(game, out .. "/00-yes-no-over-question.png")
print(("[driver] %d presses to reach the prompt, prompt seen: %s")
:format(presses, tostring(sawPrompt)))
-- YES, then let the tutorial battle play itself out (it drives its own
-- input on the cart, so all this has to do is not get in the way).
U.tap(game, "a")
for _ = 1, 200 do
U.wait(15)
if world:busy() then break end
end
for _ = 1, 400 do
U.wait(15)
if not world:busy() and game.stack:top() == game.overworld then break end
U.tap(game, "a")
end
U.wait(60)
U.shot(game, out .. "/01-after-tutorial.png")
print(("[driver] map song %s, playing now %s, dontRestartMusic %s")
:format(tostring(mapSong), tostring(Music.current()),
tostring(world.dontRestartMusic)))
print("[driver] PASS gold route 29 tutorial in " .. out)
love.event.quit()
end
+98
View File
@@ -0,0 +1,98 @@
-- The Red Gyarados, and an ordinary Miltank next to it for the contrast.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shiny_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-shiny (default)
--
-- A shiny in Gen 2 is not a second sprite: it is the SAME two-colour pic drawn
-- through the species' second palette row (data/pokemon/palettes.asm ships
-- `normal` and `shiny` for every species), which is why the Lake of Rage
-- Gyarados is red rather than a different Gyarados. Palettes.monColors is the
-- one place that picks between the two rows, so this driver asserts the rows
-- really differ, builds the mon the way the cart does -- BATTLETYPE_FORCESHINY
-- writes ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA, not a `shiny` boolean -- and
-- then shoots the battle screen so a human can see the colour.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local GbcPalette = require("src.render.GbcPalette")
-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces.
-- Attack 14, Defense 10, Speed 10, Special 10 -- which is exactly the pattern
-- Mon.isShiny tests, so nothing here has to say `shiny = true` by hand.
local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-shiny"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- A shiny only reads as one in COLOR: on a DMG both palette rows fold to the
-- same four greys, and the cart's own Gyarados is red for the same reason.
GbcPalette.setMode("gbc")
local pals = world.palettes
local normal = Palettes.monColors(pals, "GYARADOS", false)
local shiny = Palettes.monColors(pals, "GYARADOS", true)
assert(normal and shiny, "no GYARADOS palette rows in the cache")
local differ = false
for i = 1, 4 do
for c = 1, 3 do
if normal[i][c] ~= shiny[i][c] then differ = true end
end
end
assert(differ, "GYARADOS' shiny row is identical to its normal row")
U.log(("GYARADOS normal (%d,%d,%d)/(%d,%d,%d) shiny (%d,%d,%d)/(%d,%d,%d)")
:format(normal[2][1], normal[2][2], normal[2][3],
normal[3][1], normal[3][2], normal[3][3],
shiny[2][1], shiny[2][2], shiny[2][3],
shiny[3][1], shiny[3][2], shiny[3][3]))
-- The Red Gyarados is red: its shiny row is the only one of the two whose
-- brighter colour is dominated by RED. Stated as a comparison rather than a
-- literal so a re-import that shifts the 5-bit conversion still passes.
local red = shiny[2]
assert(red[1] > red[2] and red[1] > red[3],
("GYARADOS' shiny colour is not red: (%d,%d,%d)")
:format(red[1], red[2], red[3]))
local player = Mon.new(game.data, "CYNDAQUIL", 30)
assert(player and #player.moves > 0, "could not build the player's mon")
game.save.party = { player }
game.save.inventory = { POKE_BALL = 5 }
local function battleShot(species, level, dvs, name)
local mon = Mon.new(game.data, species, level, { dvs = dvs })
assert(mon, "no base data for " .. species)
if dvs == SHINY_DVS then
assert(mon.shiny,
species .. " built from the FORCESHINY DVs did not come out shiny")
else
assert(not mon.shiny, species .. " came out shiny by accident")
end
player.hp = player.maxHp
assert(world:startBattle({ wild = mon }), "startBattle failed")
local battle
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, "battle screen never came up for " .. species)
-- Let the intro slide finish so the enemy pic is fully on screen.
U.wait(90)
assert(U.shot(game, ("%s/%s.png"):format(out, name)), "no screenshot")
-- Back out: RUN is the fourth menu item, but popping the state is enough
-- for a screenshot driver and cannot fail on a speed tie.
while game.stack:top() == battle do game.stack:pop() end
U.wait(10)
end
battleShot("GYARADOS", 30, SHINY_DVS, "00-red-gyarados")
battleShot("MILTANK", 30, { attack = 15, defense = 15, speed = 15,
special = 15 }, "01-miltank")
U.log("shiny shots in " .. out)
love.event.quit()
end
@@ -0,0 +1,105 @@
-- The Vermilion Snorlax's 2x2 footprint, on the real map.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=18-pristine \
-- POKEPORT_DRIVER=tests/drivers/gold_snorlax_footprint_probe.lua love .
--
-- SPRITEMOVEDATA_BIGDOLLSYM's palette-flags byte is `STRENGTH_BOULDER |
-- BIG_OBJECT`, and IsNPCAtCoord hands a BIG_OBJECT's coordinate to
-- WillObjectIntersectBigObject -- which accepts anything in (x,y)..(x+1,y+1).
-- IsNPCAtCoord is what both `.CheckNPC` and CheckFacingObject ask, so the
-- sleeping Snorlax at (34,8) fills four cells for walking and for talking.
--
-- A screenshot goes to /tmp/gold-shots/ so the 32x32 draw can be looked at.
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
local U = dofile("tests/drivers/util.lua")
local MAP = "VERMILION_CITY"
local DOLL_X, DOLL_Y = 34, 8
local results = {}
local function claim(ok, text)
results[#results + 1] = ok and true or false
print((ok and "[snorlax] PASS " or "[snorlax] FAIL ") .. text)
end
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local resume = os.getenv("POKEPORT_GOLD_RESUME") or "18-pristine"
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[snorlax] cannot resume %s: %s"):format(resume, tostring(err)))
love.event.quit()
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local world = game.world
world.noWildEncounters = true
world:setMap(MAP, 33, 8, "right")
bot:wait(30)
bot:clearDialogue(nil, 4000)
claim(A.mapId(game) == MAP, "arrived at " .. MAP)
local doll = world:npcAt(DOLL_X, DOLL_Y)
claim(doll ~= nil and doll.bigObject == true,
"the object at (34,8) is a BIG_OBJECT")
local cells = { { 34, 8 }, { 35, 8 }, { 34, 9 }, { 35, 9 } }
local blobOk = true
for _, cell in ipairs(cells) do
if world:npcAt(cell[1], cell[2]) ~= doll then blobOk = false end
end
claim(blobOk, "all four blob cells resolve to the same object")
claim(world:npcAt(36, 8) == nil and world:npcAt(34, 10) == nil,
"and the cells just outside it are clear")
-- `.CheckNPC`: the three cells it merely overhangs refuse a step. Each is
-- approached from the far side so the walk is not blocked by the object's
-- own cell.
local walks = {
{ 35, 7, "down", "into (35,8) from above" },
{ 33, 9, "right", "into (34,9) from the left" },
{ 35, 10, "up", "into (35,9) from below" },
}
for _, row in ipairs(walks) do
world:setMap(MAP, row[1], row[2], row[3])
bot:wait(20)
local sx, sy = A.pos(game)
A.hold(game, row[3])
bot:wait(40)
A.releaseDirs(game)
bot:wait(20)
local ex, ey = A.pos(game)
claim(ex == sx and ey == sy, "a step " .. row[4] .. " is refused")
end
-- CheckFacingObject from a cell that only touches the blob's overhang.
world:setMap(MAP, 36, 9, "left")
bot:wait(20)
local talked = world:interact()
claim(talked, "an A press from (36,9) reaches the Snorlax")
bot:clearDialogue(nil, 6000)
world:setMap(MAP, 33, 8, "right")
bot:wait(40)
U.shot(game, "/tmp/gold-shots/snorlax-footprint.png")
local failures = 0
for _, value in ipairs(results) do
if not value then failures = failures + 1 end
end
print(("[snorlax] %d claims, %d failed"):format(#results, failures))
love.event.quit()
end
+85
View File
@@ -0,0 +1,85 @@
-- SPRITEMOVEDATA_SPINCLOCKWISE / _SPINCOUNTERCLOCKWISE, the two spins that are
-- not random.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_spin_clockwise.lua love .
--
-- MovementFunction_SpinClockwise / _SpinCounterclockwise
-- (engine/overworld/map_objects.asm:790-843) hold OBJECT_STEP_DURATION $10 --
-- sixteen frames -- on each quarter and then take the next facing out of a
-- FIXED table: clockwise is down -> left -> up -> right, counterclockwise is
-- down -> right -> up -> left. No Random is called anywhere in the loop, which
-- is the point: the Rocket base's guards are a puzzle, and a puzzle has to be
-- predictable.
--
-- The port used to answer both rows with "stand", so Route 32's Youngster
-- Gordon, Route 35's Firebreather Walt, RadioTower4F's GruntM10 and the Route
-- 40/41 swimmers never turned at all.
local U = require("tests.drivers.util")
local CLOCKWISE = { down = "left", up = "right", left = "up", right = "down" }
local COUNTER = { down = "right", up = "left", left = "down", right = "up" }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-spin"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Route 32 has Youngster Gordon on SPRITEMOVEDATA_SPINCLOCKWISE ($1f); the
-- driver takes whatever spinners the map actually carries rather than naming
-- an object index, so a cache rebuild cannot silently retarget it.
world:setMap("ROUTE_32", 9, 12, "down")
U.wait(30)
local found = {}
for _, npc in ipairs(world.npcs) do
local m = npc.def and npc.def.movement
if m == 0x1f or m == 0x1e then
found[#found + 1] = { npc = npc, cw = (m == 0x1f) }
end
end
print(("[driver] %d fixed-spin objects on ROUTE_32"):format(#found))
assert(#found > 0, "ROUTE_32 has no SPINCLOCKWISE / SPINCOUNTERCLOCKWISE object")
U.shot(game, out .. "/00-before.png")
-- Sixteen frames a quarter, so 200 frames is a dozen turns even allowing for
-- the initial timer NPC.new seeds.
local seen = {}
for _, e in ipairs(found) do seen[e.npc] = { [e.npc.facing] = true } end
local order = {}
for _, e in ipairs(found) do order[e.npc] = {} end
for _ = 1, 200 do
for _, e in ipairs(found) do
local f = e.npc.facing
local trail = order[e.npc]
if trail[#trail] ~= f then trail[#trail + 1] = f end
seen[e.npc][f] = true
end
U.wait(1)
end
U.shot(game, out .. "/01-after.png")
for i, e in ipairs(found) do
local n = 0
for _ in pairs(seen[e.npc]) do n = n + 1 end
local trail = order[e.npc]
print(("[driver] object %d (%s): %d distinct facings over %d turns: %s")
:format(i, e.cw and "clockwise" or "counterclockwise", n, #trail - 1,
table.concat(trail, ">")))
assert(n == 4,
"a fixed spinner only reached " .. n .. " facings -- it is not turning")
-- Every consecutive pair has to be the table's own successor: a RANDOM
-- spin would pass the count above and fail here.
local want = e.cw and CLOCKWISE or COUNTER
for j = 2, #trail do
assert(trail[j] == want[trail[j - 1]],
("turn %d went %s -> %s, wanted %s"):format(j - 1, trail[j - 1],
trail[j], tostring(want[trail[j - 1]])))
end
end
print("[driver] PASS gold fixed-order spinners in " .. out)
love.event.quit()
end
+137
View File
@@ -0,0 +1,137 @@
-- The starter choice at Elm's lab, which is the first `pokepic` a player meets.
--
-- maps/ElmsLab.asm, ElmsLabPokeBallScript (one per ball, Cyndaquil's at (6,3)):
--
-- turnobject ELMSLAB_ELM, DOWN
-- reanchormap
-- pokepic CYNDAQUIL
-- cry CYNDAQUIL
-- waitbutton
-- closepokepic
-- opentext
-- writetext ElmsLabText_ChooseCyndaquil
-- yesorno
--
-- The pic goes up with NO text window under it, so nothing else in that run of
-- commands consumes a button press: `waitbutton` (Script_waitbutton ->
-- WaitButton, home/text.asm) is the ONLY thing holding the frame, and it is
-- what gives the player time to look at the mon before the yes/no. The port
-- used to treat every `waitbutton` as already-paid-for by the text box that
-- usually precedes it, so pokepic / closepokepic ran inside one VM resume and
-- the pic was created and destroyed without a single frame drawing it (#911).
--
-- This driver stands in front of the Cyndaquil ball, presses A, and counts the
-- frames where World.pokePic is actually up. It shoots the popup, so a human
-- can see the pic and not just a number.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_starter_pic.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-starter \
-- perl -e 'alarm 280; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-starter"
-- main.lua's love.visible / love.focus handlers call Game:visible / Game:focus,
-- and src/core/Game2.lua defines neither -- so on Gold any window event (another
-- app taking the screen, which is constant when drivers share a machine) kills
-- the run before a single assertion prints. Fixing that belongs in Game2, not
-- in a driver; surviving it belongs here, so this run swallows the two
-- callbacks. Loaded from love.load, i.e. after main.lua has installed its own.
function love.visible() end
function love.focus() end
return function(game)
local w = game.world
local fails = 0
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function ok(cond, msg)
if cond then
print("[starter] ok " .. msg)
else
fails = fails + 1
print("[starter] FAIL " .. msg)
end
return cond
end
local function shot(name)
local path = SHOT_DIR .. "/" .. name .. ".png"
game.capturePath = path
for _ = 1, 120 do
if not game.capturePath then break end
coroutine.yield()
end
wait(1)
local f = io.open(path, "rb")
if f then f:close() return true end
print("[starter] FAIL screenshot did not reach disk: " .. path)
fails = fails + 1
return false
end
local function tap(btn)
table.insert(game.input.pressQueue, btn)
coroutine.yield()
game.input.state[btn] = false
end
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
wait(45)
-- SCENE_ELMSLAB_NOTHING: the meet-Elm walk-in has already played, which is
-- the state a player is in when they walk over to the balls.
w.mapScenes.ELMS_LAB = 1
w:setMap("ELMS_LAB", 6, 4, "up")
wait(20)
local shown, hidden = 0, 0
local realShow, realHide = w.showPokePic, w.hidePokePic
w.showPokePic = function(self, species)
shown = shown + 1
return realShow(self, species)
end
tap("a")
-- Count the frames the pic is actually up. The pic is a field on the world
-- rather than a pushed state, so this is the same thing love.draw reads.
local picFrames, sawPic = 0, false
for i = 1, 260 do
if w.pokePic then
picFrames = picFrames + 1
if not sawPic then
sawPic = true
shot("01_starter_popup")
end
end
if sawPic and not w.pokePic then break end
-- Press A again only once the pic has been on screen a while, so the
-- driver measures the hold instead of ending it on frame one.
if picFrames == 90 then tap("a") end
if i % 4 == 0 and not sawPic then tap("a") end
coroutine.yield()
end
ok(shown > 0, "the ball script ran `pokepic` (" .. shown .. " call(s))")
ok(picFrames > 0,
"and the pic was on screen for " .. picFrames .. " drawn frame(s)")
ok(picFrames >= 30,
"long enough to read: `waitbutton` holds it until the player presses A")
-- The yes/no follows the pic, so a run that got this far is at the prompt.
for _ = 1, 120 do
if w.choicebox or game.stack:top() ~= game.overworld then break end
coroutine.yield()
end
-- The box opens empty and types itself in over the next few frames, so a
-- capture on the frame it appeared would photograph a blank window and
-- prove nothing about the question.
wait(60)
shot("02_confirm_prompt")
if fails > 0 then
error(("gold starter pic: %d assertion(s) failed"):format(fails))
end
print("[driver] PASS gold starter pic: pokepic holds until A, then asks")
end
+130
View File
@@ -0,0 +1,130 @@
-- Route 36's Sudowoodo and Route 37's TWINS ANN & ANNE share ONE
-- wVariableSprites slot, and the fight is what repaints it.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_sudowoodo_twins.lua love .
--
-- maps/Route36.asm:486 gives the tree `SPRITE_WEIRD_TREE`, and maps/Route37.asm
-- :237-238 give BOTH twins the same byte -- it is a SLOT ($f4), not a sheet.
-- InitializeEventsScript seeds the slot with SPRITE_SUDOWOODO, and
-- WateredWeirdTreeScript's `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN`
-- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) is the
-- only thing that ever repaints it. Miss that command and the two girls on
-- Route 37 are drawn as a pair of Sudowoodo.
--
-- Shots land in /tmp/gold-twins: the twins before the fight (Sudowoodo, which
-- is what the cart draws too -- the tree blocks the only road north), the tree
-- itself, and the twins after.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function twinSprites(world)
local out = {}
for _, npc in ipairs(world.npcs) do
if npc.def and (npc.def.index == 1 or npc.def.index == 2) then
out[#out + 1] = (npc.spriteDef and npc.spriteDef.id) or "?"
end
end
return out
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-twins"
local function tap(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
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
print(("[driver] slot 4 seeds to %s (SPRITE_SUDOWOODO is 82)")
:format(tostring(world.variableSprites[4])))
-- Before: Route 37, the two twins standing at (6,12) and (7,12).
world:setMap("ROUTE_37", 6, 14, "up")
U.wait(20)
print("[driver] twins before: " .. table.concat(twinSprites(world), ", "))
U.shot(game, out .. "/00-twins-before.png")
-- The fight. A SQUIRTBOTTLE in the bag is what turns the A press into
-- WateredWeirdTreeScript rather than the shake-and-nothing arm.
local Bag = require("src.inventory.Bag")
local starter = Mon.new(game.data, "TYPHLOSION", 60)
assert(starter, "could not build a TYPHLOSION")
game.save.party = { starter }
Bag.add(game.save, "SQUIRTBOTTLE", 1, game.data)
world:setMap("ROUTE_36", 34, 9, "right")
U.wait(20)
U.shot(game, out .. "/01-tree.png")
world:interact()
U.wait(4)
-- yesorno: "Use the SQUIRTBOTTLE?" -> YES is the default cursor row.
local battle
for _ = 1, 400 do
local top = game.stack:top()
if top and top.battle then battle = top break end
tap("a", 2)
end
assert(battle, "the Sudowoodo battle never started")
U.shot(game, out .. "/02-battle.png")
local attackSlot = 1
for i, move in ipairs(starter.moves) do
local def = game.data.moves and game.data.moves[move.id]
if def and (def.power or 0) > 0 then attackSlot = i break end
end
for _ = 1, 900 do
if battle.battle.over then break end
if battle.phase == "menu" then
tap("a")
U.wait(4)
for _ = 2, attackSlot do tap("down", 2) end
tap("a")
else
tap("a", 3)
end
end
assert(battle.battle.over, "the Sudowoodo battle did not resolve")
print("[driver] outcome " .. tostring(battle.battle.outcome))
for _ = 1, 300 do
if not world:busy() then break end
tap("a", 2)
end
print(("[driver] slot 4 after the fight: %s (SPRITE_TWIN is 38)")
:format(tostring(world.variableSprites[4])))
U.shot(game, out .. "/03-after-fight.png")
-- The half a plain setMap cannot see. Route 37 is a CONNECTION of Route 36,
-- so its objects are already pooled as ghosts on the neighbor strip -- with
-- the sheet the slot held when they were pooled, i.e. SPRITE_SUDOWOODO --
-- and walking north is a SEAMLESS setMap that KEEPS World.npcPool. Only
-- World:repaintVariableSpritePool hands them the new sheet.
for _, key in ipairs({ "ROUTE_37_obj_1", "ROUTE_37_obj_2" }) do
local ghost = world.npcPool[key]
print(("[driver] pooled %s: %s"):format(key,
ghost and tostring(ghost.spriteDef and ghost.spriteDef.id) or "not pooled"))
assert(not ghost or (ghost.spriteDef and ghost.spriteDef.id) == "SPRITE_TWIN",
key .. " is still pooled as " .. tostring(ghost.spriteDef and ghost.spriteDef.id))
end
world:setMap("ROUTE_37", 6, 14, "up", { seamless = true })
U.wait(20)
local after = twinSprites(world)
print("[driver] twins after: " .. table.concat(after, ", "))
U.shot(game, out .. "/04-twins-after.png")
assert(world.variableSprites[4] == 38,
"wVariableSprites[SPRITE_WEIRD_TREE] is "
.. tostring(world.variableSprites[4]) .. ", wanted 38 (SPRITE_TWIN)")
for _, id in ipairs(after) do
assert(id == "SPRITE_TWIN", "a twin is drawn as " .. tostring(id))
end
print("[driver] PASS gold sudowoodo -> twins in " .. out)
love.event.quit()
end
+125
View File
@@ -0,0 +1,125 @@
-- Screenshots of the mon SUMMARY (engine/pokemon/stats_screen.asm), which is
-- the one thing tests/gen2_summary_test.lua cannot check: it asserts every
-- hlcoord, but not whether the three pages read like Gold's.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_summary_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-summary (default)
--
-- Boots into the world (Game2 skips the cinema under POKEPORT_DRIVER),
-- builds a party through the ONE Gen 2 builder so the mons actually have
-- stats, moves, PP and experience, then puts each page up.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-summary"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
local function show(name, state)
game.stack:push(state)
shot(name)
game.stack:pop()
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.player.name = "GOLD"
save.player.id = 12345
-- Mon.new is the only party-member builder: anything routed through Gen 1's
-- Pokemon.new comes back with no moves at all, because a Gen 2 moveset is
-- `levelMoves` and Gen 1 reads level1Moves / learnset.
local function build(species, level, opts)
opts = opts or {}
local mon = Mon.new(game.data, species, level, {
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
})
assert(mon, "no base data for " .. species)
mon.nickname = opts.nickname or mon.name
mon.otName = save.player.name
mon.otId = save.player.id
for key, value in pairs(opts.fields or {}) do mon[key] = value end
-- Spend some PP so the PP columns are not four identical pairs, and take
-- a bite out of the HP so the bar is not always full green.
for i, move in ipairs(mon.moves or {}) do
move.pp = math.max(0, (move.maxPp or move.pp) - i * 3)
end
return mon
end
save.party = {
-- A held item, a status, and a dual-typed third mon so the pink page shows
-- both type rows.
build("CYNDAQUIL", 22, { fields = { item = "BERRY" } }),
build("TOTODILE", 18, { fields = { status = "psn" } }),
build("GASTLY", 15, {}),
}
save.party[1].hp = math.floor(save.party[1].maxHp * 0.4)
save.party[2].hp = math.floor(save.party[2].maxHp * 0.15)
-- Part way to the next level, so the exp bar is not empty or full.
local growth = game.data.pokemon.growthRates
local function partWay(mon)
local def = game.data.pokemon[mon.species]
local rate = growth and def and growth[def.growthRate]
if not rate then return end
local base = Mon.experienceForLevel(rate, mon.level)
local next_ = Mon.experienceForLevel(rate, mon.level + 1)
mon.experience = base + math.floor((next_ - base) * 0.6)
end
for _, mon in ipairs(save.party) do partWay(mon) end
-- The party list the summary is opened from, and the action submenu STATS
-- lives in (engine/pokemon/mon_submenu.asm).
local party = PartyMenu.new(game, { prompt = "choose", submenu = true })
show("00-party", party)
party:openSubmenu()
show("01-mon-submenu", party)
party:closeSubmenu()
-- The three pages, in the order .d_right walks them.
local function page(n)
local screen = SummaryMenu.new(game, {
party = save.party, index = 1, save = save, page = n,
})
return screen
end
show("02-pink-page", page(SummaryMenu.PINK_PAGE))
show("03-green-page", page(SummaryMenu.GREEN_PAGE))
show("04-blue-page", page(SummaryMenu.BLUE_PAGE))
-- ...and the same three for the poisoned mon, whose HP bar is red and whose
-- status line is not OK.
local hurt = SummaryMenu.new(game, {
party = save.party, index = 2, save = save,
})
show("05-pink-page-poisoned", hurt)
hurt.page = SummaryMenu.BLUE_PAGE
show("06-blue-page-second-mon", hurt)
-- The dual-typed mon, for the second TYPE row.
local ghost = SummaryMenu.new(game, {
party = save.party, index = 3, save = save,
})
show("07-pink-page-dual-type", ghost)
-- PlaceMoveData's screen, which is where the cart shows a move description.
local detail = SummaryMenu.new(game, {
party = save.party, index = 1, save = save,
page = SummaryMenu.GREEN_PAGE,
})
detail.moveDetail = true
show("08-move-description", detail)
detail.moveIndex = 2
show("09-move-description-second", detail)
print("[driver] PASS gold summary shots in " .. out)
end
+103
View File
@@ -0,0 +1,103 @@
-- NewBarkTown_TeacherStopsYouScene1/2, the coord event that stops you leaving
-- New Bark Town before Elm has given you a mon.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_teacher_scene.lua love .
--
-- What this is watching for:
-- * `follow NEWBARKTOWN_TEACHER, PLAYER` drags the player back into town
-- behind her. Without it the player never leaves the coord event's tile,
-- the scene fires again the moment it ends, and she is back at her spawn
-- starting the same speech over -- which is what "she jumps back to her
-- original spot" looks like from the outside.
-- * she should be standing NEXT TO the player for the middle line, not back
-- at (6,8).
--
-- Shots land in /tmp/gold-teacher; the position trace goes to stdout.
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-teacher"
local function tap(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 4)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU is scene 0, the map's starting scene,
-- so a fresh save is already in it.
world:setMap("NEW_BARK_TOWN", 2, 8, "left")
U.wait(30)
U.shot(game, out .. "/00-before.png")
local function teacher()
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.index == 1 then return npc end
end
return nil
end
local function trace(tag)
local t = teacher()
print(("[driver] %-14s player=(%d,%d) teacher=(%s,%s) scene=%d busy=%s")
:format(tag, world.player.cellX, world.player.cellY,
t and tostring(t.cellX) or "-", t and tostring(t.cellY) or "-",
world:scene(), tostring(world:busy())))
end
trace("start")
-- One step left onto (1,8), the coord event's tile.
tap("left", 30)
trace("stepped")
-- Page the scene through. The middle shot is the one that matters: she has
-- to be standing next to the player, not back at her spawn.
local shots, adjacent = 0, false
for step = 1, 400 do
local t = teacher()
if t and math.abs(t.cellX - world.player.cellX) <= 1
and t.cellY == world.player.cellY then
if not adjacent then
adjacent = true
U.shot(game, out .. "/01-she-is-here.png")
trace("adjacent")
end
end
if step % 25 == 0 then
shots = shots + 1
U.shot(game, ("%s/02-scene-%02d.png"):format(out, shots))
trace("frame " .. shots)
end
-- Done when the scene has finished AND the player has been walked off the
-- trigger tile.
if not world:busy() and world.player.cellX ~= 1 and step > 20 then break end
tap("a", 4)
end
trace("scene over")
U.shot(game, out .. "/03-after.png")
-- Hands off for a second: nothing may re-trigger.
local restarted = false
for _ = 1, 120 do
if world:busy() then restarted = true end
U.wait(1)
end
trace("idle")
local dragged = world.player.cellX ~= 1
print(("[driver] the player was walked back into town: %s")
:format(tostring(dragged)))
print(("[driver] she stood next to the player mid-scene: %s")
:format(tostring(adjacent)))
print(("[driver] the scene re-triggered while idle: %s")
:format(tostring(restarted)))
print(("[driver] %s gold teacher scene in %s")
:format((dragged and adjacent and not restarted) and "PASS" or "FAIL", out))
love.event.quit()
end
+20
View File
@@ -0,0 +1,20 @@
-- TILT on the Gen 2 overworld: the world pass rendered into a canvas and
-- projected onto the perspective quad, with the flat frame beside it for
-- comparison. Shots land in /tmp/gold-tilt.
local U = require("tests.drivers.util")
local Tilt = require("src.render.Tilt")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-tilt"
U.wait(45)
U.shot(game, out .. "/00-flat.png")
for level = 1, 3 do
Tilt.setLevel(level)
-- The angle eases in, so let the tween finish before the shot.
U.wait(60)
U.shot(game, ("%s/%02d-tilt%d.png"):format(out, level, level))
end
Tilt.setLevel(0)
U.wait(60)
print("[driver] PASS gold tilt shots in " .. out)
end
+50
View File
@@ -0,0 +1,50 @@
-- The Gold title screen, once per COLOR mode.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_title_shots.lua love .
--
-- Two things about this screen only a human can check, and both of them are
-- what this driver puts on disk:
--
-- * Ho-Oh's placement. `depixel 12, 11` is OAM (x 88, y 96), and OAM is
-- biased by (-8, -16), so the bird's 64 pixels land on 48..112 -- dead
-- centre of the 160-wide screen. A shot where it sits right of centre
-- means the bias (or the y-then-x argument order) has been dropped again.
-- * Ho-Oh under DMG and CLASSIC. LoadTitleScreenPals writes rOBP0 =
-- %11111111 on a non-CGB screen, mapping all four of the pic's colours to
-- shade 3, so the bird is a solid BLACK silhouette there rather than the
-- shaded pose a straight 2bpp decode gives.
--
-- Writes to /tmp/gold-title (POKEPORT_SHOT_DIR to move it).
local U = require("tests.drivers.util")
local GbcPalette = require("src.render.GbcPalette")
local TitleState = require("src.ui.gen2.TitleState")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-title"
U.wait(10)
game:showTitle()
U.wait(20)
local title = game.stack:top()
assert(getmetatable(title) == TitleState,
"showTitle left " .. tostring(title) .. " on the stack")
assert(title.hoohX == 48 and title.hoohY == 56,
("Ho-Oh is at (%s, %s), expected (48, 56) -- stale cache?")
:format(tostring(title.hoohX), tostring(title.hoohY)))
local previous = GbcPalette.mode
for _, mode in ipairs(GbcPalette.MODES) do
GbcPalette.setMode(mode)
-- Land on the same wing-flap frame in every mode so the three shots
-- differ only in colour: the frameset runs on its own timer.
title.seqIndex, title.frame, title.seqLeft = 1, 1, 999
title.hoohPhase = 0
U.wait(2)
U.shot(game, ("%s/title-%s.png"):format(out, mode))
print(("[driver] %s"):format(GbcPalette.modeLabel(mode)))
end
GbcPalette.setMode(previous)
print("[driver] PASS gold title shots in " .. out)
end
+119
View File
@@ -0,0 +1,119 @@
-- The mobile on-screen pad, on Gold. Gen 1 has had it since #415
-- (src/core/TouchControls.lua, Xelu's CC0 art in assets/touch/); Gold drew
-- nothing at all, so a phone player without a controller had no way to press
-- anything. Same module, same art, same options.touchControls layout -- what
-- was missing was every seam in src/core/Game2.lua that has to reach it.
--
-- POKEPORT_TOUCH=1 POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_touch_controls.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-touch (default)
--
-- POKEPORT_TOUCH=1 is what forces the overlay onto a desktop, and main.lua
-- then routes the mouse into love.touch* as a stand-in finger, which is the
-- same path a real finger takes -- so driving Game2:touchpressed here exercises
-- exactly what a phone does.
--
-- 01-pad.png the pad over the overworld, nothing held
-- 02-pressed.png A and RIGHT held, both controls lit
--
-- The assertions are the half a picture cannot show: that a press on a control
-- actually reaches Input as a GB button under the overlay's own source name,
-- that the d-pad swaps direction on a slide without ever double-holding, that a
-- captured touch is NOT offered to the mod pointer hook, and that a controller
-- press puts the pad away.
local U = require("tests.drivers.util")
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-touch"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[touch] ok " .. label)
else
failures = failures + 1
print("[touch] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
ok("Game2 owns the shared overlay", game.touchControls == TouchControls)
ok("POKEPORT_TOUCH=1 forces it on for this desktop run",
TouchControls.active == true)
ok("and it has art to draw", TouchControls:visible(),
tostring(TouchControls.img))
if not TouchControls:visible() then
print("FAIL gold_touch_controls (no overlay)")
love.event.quit(1)
return
end
local L = TouchControls:layout()
U.shot(game, out .. "/01-pad.png")
-- A: press, hold, release. The overlay presses GB buttons through
-- Input:overlayPressed rather than a keyboard alias, so isTouchDown is the
-- proof it went through the pad and not through some other source.
game:touchpressed("f1", L.a.cx, L.a.cy)
ok("a finger on A holds GB A", Input:isDown("a"))
ok("under the overlay's own input source", Input:isTouchDown("a"))
-- The d-pad, and the slide between directions the pad exists for.
game:touchpressed("f2", L.dpad.cx + L.dpad.w * 0.4, L.dpad.cy)
ok("a finger right of the d-pad centre holds RIGHT", Input:isDown("right"))
U.shot(game, out .. "/02-pressed.png")
game:touchmoved("f2", L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4)
ok("sliding it up swaps the hold to UP", Input:isDown("up"))
ok("and RIGHT is no longer held", not Input:isDown("right"))
game:touchreleased("f2", L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4)
ok("lifting it drops UP", not Input:isDown("up"))
ok("without dropping A, which another finger still owns", Input:isDown("a"))
game:touchreleased("f1", L.a.cx, L.a.cy)
ok("and lifting that finger drops A", not Input:isDown("a"))
-- Two fingers on one button: the second must not double-press it, and
-- lifting one must not release the other's hold (TouchControls.held counts).
game:touchpressed("f3", L.b.cx, L.b.cy)
game:touchpressed("f4", L.b.cx + 1, L.b.cy + 1)
game:touchreleased("f3", L.b.cx, L.b.cy)
ok("two fingers on B: lifting one keeps B held", Input:isDown("b"))
game:touchreleased("f4", L.b.cx + 1, L.b.cy + 1)
ok("lifting the second drops it", not Input:isDown("b"))
-- The pointer seam (#807): a touch the pad captured belongs to the pad for
-- its whole life and must never be offered to a mod.
game.modPointers = nil
game:touchpressed("f5", L.start.cx, L.start.cy)
ok("a captured touch never becomes a mod pointer",
game.modPointers == nil or game.modPointers.f5 == nil)
game:touchreleased("f5", L.start.cx, L.start.cy)
-- A controller press puts the pad away until the next screen touch. The
-- release matters: `a` is GB A on the pad map too, and a hold left standing
-- here would be indistinguishable from the overlay pressing it below.
game:gamepadpressed(nil, "a")
game:gamepadreleased(nil, "a")
ok("a controller press hides the pad", not TouchControls:visible())
game:touchpressed("f6", L.a.cx, L.a.cy)
ok("and the next touch only brings it back, it does not press",
TouchControls:visible() and not Input:isDown("a"))
game:touchreleased("f6", L.a.cx, L.a.cy)
-- SELECT on a controller. `back` is SDL's name for the PS CREATE/SHARE
-- button (and Xbox VIEW, and Switch MINUS); src/core/GamepadMap.lua maps it
-- to GB SELECT, and Game2 used to swallow it with love.event.quit().
game:gamepadpressed(nil, "back")
ok("the pad's back/CREATE button presses GB SELECT", Input:isDown("select"))
game:gamepadreleased(nil, "back")
ok("and releases it", not Input:isDown("select"))
print(failures == 0 and "PASS gold_touch_controls"
or ("FAIL gold_touch_controls (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+136
View File
@@ -0,0 +1,136 @@
-- Smoke: a Route 30 trainer spots the player, walks up, and battles.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_trainer_smoke.lua love .
--
-- This is the whole overworld trainer path in one run: the `trainer` struct the
-- extractor now reads off OBJECTTYPE_TRAINER objects, the eyesight test from
-- home/trainers.asm, the approach walk, the seen text, a real battle against
-- the class's extracted party, and the beat flag that stops it re-triggering.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-trainer"
local function tap(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
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- A trainer only challenges a player who has a party, the same as the cart.
local starter = Mon.new(game.data, "CYNDAQUIL", 20)
assert(starter, "could not build a CYNDAQUIL from pokemon.lua")
game.save.party = { starter }
world:setMap("ROUTE_30", 5, 33, "up")
U.wait(20)
assert(world.map.id == "ROUTE_30", "setMap: " .. tostring(world.map.id))
-- Object 4 is Route 30's BUG_CATCHER, sight 3, standing at (4,7). JOEY
-- (object 2) is the more famous one but InitializeEventsScript hides him
-- until the Mr. Pokemon errand, so he is not on the map yet.
local foe
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.trainer and npc.def.index == 4 then foe = npc end
end
assert(foe, "Route 30's BUG_CATCHER object has no trainer struct")
assert(foe.def.trainer.class == 36 and foe.def.trainer.member == 1,
("expected BUG_CATCHER member 1, got class %s member %s"):format(
tostring(foe.def.trainer.class), tostring(foe.def.trainer.member)))
assert(foe.def.sight == 3,
"expected sight 3, got " .. tostring(foe.def.sight))
assert(not world:trainerBeaten(foe.def.trainer), "the trainer starts beaten")
-- Put the player in his line of sight, three cells below him, and face him
-- down the column so the eyesight test fires on the next settled step.
foe.facing = "down"
world.player.cellX, world.player.cellY = foe.cellX, foe.cellY + 3
world.player.px = world.player.cellX * 16
world.player.py = world.player.cellY * 16
local fired = false
for _ = 1, 60 do
if world:busy() then fired = true break end
world:checkTrainerBattle()
U.wait(1)
end
assert(fired, "the trainer never noticed the player")
U.shot(game, out .. "/01-spotted.png")
-- PlayTrainerEncounterMusic plays the CLASS's own jingle while he walks up
-- (data/trainers/encounter_music.asm), not the battle theme; PlayBattleMusic
-- swaps that in a moment later, when the transition starts.
local Music = require("src.core.Music")
local encounter = Music.current()
print("[driver] encounter music " .. tostring(encounter))
assert(encounter and encounter:match("^Music_Look"),
"expected a Music_Look* encounter jingle, got " .. tostring(encounter))
-- The bubble is up and he closes to one cell short of the player.
local sawEmote = world.emote ~= nil
for _ = 1, 240 do
if world.emote then sawEmote = true end
if game.stack:top() ~= nil and game.stack:top().battle then break end
tap("a", 2)
end
assert(sawEmote, "no ! bubble was shown")
assert(math.abs(foe.cellY - world.player.cellY) == 1,
("the trainer stopped %d cells away, expected 1"):format(
math.abs(foe.cellY - world.player.cellY)))
local battle = game.stack:top()
assert(battle and battle.battle, "no battle screen after the seen text")
assert(battle.battle.trainer, "battle is not a trainer battle")
print("[driver] battle music " .. tostring(Music.current()))
assert(Music.current() == "Music_JohtoTrainerBattle",
"a Johto bug catcher should fight to the Johto trainer theme, got "
.. tostring(Music.current()))
print("[driver] fighting " .. tostring(battle.battle.trainer.name))
assert(#battle.battle.trainer.party > 0, "trainer party is empty")
U.shot(game, out .. "/02-battle.png")
-- Pick a damaging move rather than slot 1: CYNDAQUIL's L20 window leads
-- with LEER, and two attackers who cannot hurt each other never finish.
local attackSlot = 1
for i, move in ipairs(starter.moves) do
local def = game.data.moves and game.data.moves[move.id]
if def and (def.power or 0) > 0 then attackSlot = i break end
end
for _ = 1, 600 do
if battle.battle.over then break end
if battle.phase == "menu" then
tap("a") -- FIGHT
U.wait(4)
for _ = 2, attackSlot do tap("down", 2) end
tap("a")
else
tap("a", 3)
end
end
assert(battle.battle.over,
"trainer battle did not resolve (phase " .. tostring(battle.phase) .. ")")
assert(battle.battle.outcome == "win",
"expected the L20 starter to win, got " .. tostring(battle.battle.outcome))
-- The after-battle text runs, then the beat flag stops the rematch.
for _ = 1, 200 do
if not world:busy() then break end
tap("a", 2)
end
assert(not world:busy(), "the trainer script never finished")
assert(world:trainerBeaten(foe.def.trainer),
"the beat flag was not set after the win")
assert(not world:checkTrainerBattle(),
"a beaten trainer challenged the player again")
U.shot(game, out .. "/03-after.png")
print("[driver] PASS gold overworld trainer battle in " .. out)
end
+64
View File
@@ -0,0 +1,64 @@
-- DoBattleTransition, frame by frame.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_transition_shots.lua love .
--
-- Shoots each of the four outros (spin / speckle / sine / zoom) plus the Poke
-- Ball overlay a trainer battle stamps over the map first. POKEPORT_SHOT_INTERVAL
-- picks the sampling; the default walks the whole thing at 6 frames.
--
-- Shots land in /tmp/gold-transition/<style>/.
local U = require("tests.drivers.util")
local Transition = require("src.ui.gen2.BattleTransition")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-transition"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or 6
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world:setMap("NEW_BARK_TOWN", 5, 8, "down")
U.wait(20)
local function run(style, trainer)
local done = false
game.stack:push(Transition.new(game, {
world = world,
style = style,
trainer = trainer,
onDone = function() done = true end,
}))
-- Shoot the flash sparsely and the outro (the part with the shape in it)
-- at every interval, so a nine-shot run is not all palette pulses.
local state = game.stack:top()
local shots, outroShots = 0, 0
for frame = 1, 600 do
local outro = state.phase == "outro"
local want = outro and (outroShots % interval == 0)
or (not outro and frame % 24 == 1)
if want then
shots = shots + 1
U.shot(game, ("%s/%s/%s-%02d.png")
:format(out, style, outro and "outro" or "flash", shots))
end
if outro then outroShots = outroShots + 1 end
if done then break end
U.wait(1)
end
print(("[driver] %-8s %d shots, finished=%s")
:format(style, shots, tostring(done)))
-- The state pops itself; if it did not, take it off so the next run starts
-- from a clean stack.
if not done then game.stack:pop() end
U.wait(5)
end
run("spin", true)
run("speckle", false)
run("sine", false)
run("zoom", false)
print("[driver] shots in " .. out)
love.event.quit()
end
+181
View File
@@ -0,0 +1,181 @@
-- Navigation probe: can the bot WALK from A to B?
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=05 \
-- POKEPORT_GOLD_PROBE="CHERRYGROVE_CITY>ILEX_FOREST,VIOLET_CITY>AZALEA_TOWN" \
-- POKEPORT_DRIVER=tests/drivers/gold_travel_probe.lua love .
--
-- Every teleport the route bot logs is a map the planner could not reach, and
-- each one used to cost a ten-minute run to reproduce. This puts the player at
-- the start map with the checkpoint's badges and HMs, asks Bot:travelTo for the
-- destination, and reports hops and frames -- so a planner change is a
-- one-minute experiment instead of a full replay.
--
-- The placement itself is A.teleport, the same harness shortcut the bot falls
-- back on; it is the SETUP here, never the measurement. What is measured is
-- only the walk that follows.
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
local DEFAULT = "CHERRYGROVE_CITY>ILEX_FOREST"
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
if not A.ready(game) then
print("[probe] the world never came up")
return
end
local resume = os.getenv("POKEPORT_GOLD_RESUME")
if resume then
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[probe] cannot resume %s: %s"):format(resume, tostring(err)))
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
end
-- Single-map walk probe: "ROUTE_32@18,6>6,79" places the player and asks for
-- one local walk. Travel failures usually bottom out in one map's local
-- pathfinding, and isolating that is the difference between a 60k-frame
-- reproduction and a 400-frame one.
local walkSpec = os.getenv("POKEPORT_GOLD_WALK")
if walkSpec then
for spec in walkSpec:gmatch("[^;]+") do
local map, sx, sy, gx, gy =
spec:match("^%s*([%w_]+)@(%-?%d+),(%-?%d+)>(%-?%d+),(%-?%d+)%s*$")
if not map then
print(("[probe] cannot parse walk %q"):format(spec))
else
if not A.teleport(game, map, tonumber(sx), tonumber(sy)) then
print(("[probe] cannot place the player on %s"):format(map))
else
bot:wait(30)
bot:clearDialogue()
bot:progress()
local start = bot:frames()
local ok, res = pcall(function()
return bot:walkTo(tonumber(gx), tonumber(gy))
end)
local px, py = A.pos(game)
print(("[probe] walk %s: %s in %d frames, ended at (%s,%s) on %s")
:format(spec, (ok and res) and "ok" or "FAIL",
bot:frames() - start, tostring(px), tostring(py),
tostring(A.mapId(game))))
-- Who is standing where, now. An NPC blocks a step exactly like a
-- wall but appears nowhere in the extracted map, so a corridor that
-- looks two cells wide on paper can be one cell wide in play -- which
-- is the difference between "the planner is wrong" and "there is no
-- way round".
local map = A.map(game)
if map and px then
local near = {}
for cy = math.max(0, py - 6), math.min(map.heightCells - 1, py + 6) do
for cx = 0, map.widthCells - 1 do
if A.npcAt(game, cx, cy) then
near[#near + 1] = ("(%d,%d)"):format(cx, cy)
end
end
end
print(("[probe] npcs within 6 rows: %s")
:format(#near > 0 and table.concat(near, " ") or "none"))
end
bot:progress()
end
end
end
return
end
local pairsSpec = os.getenv("POKEPORT_GOLD_PROBE") or DEFAULT
local budget = tonumber(os.getenv("POKEPORT_GOLD_PROBE_BUDGET")) or 60000
local results = {}
for spec in pairsSpec:gmatch("[^,]+") do
-- "A>B" or "A>B#N", where N pins which REGION of B counts as arriving.
-- Split maps are the whole reason travelTo grew a region argument
-- (TEAM_ROCKET_BASE_B3F), so the probe has to be able to ask for one or it
-- cannot test the thing that matters.
local from, to, region = spec:match("^%s*([%w_]+)%s*>%s*([%w_]+)%s*#(%d+)%s*$")
if not from then
from, to = spec:match("^%s*([%w_]+)%s*>%s*([%w_]+)%s*$")
end
region = tonumber(region)
if not from then
print(("[probe] cannot parse %q"):format(spec))
else
-- Place the player at `from` (setup, not navigation), then walk.
local defs = bot:mapDefs()
local def = defs[from]
local landing = def and (def.warps or {})[1]
local placed
if landing then
placed = A.teleport(game, from, landing.x, landing.y)
else
placed = A.teleport(game, from, 0, 0)
end
if not placed then
print(("[probe] cannot place the player on %s"):format(from))
results[#results + 1] = { spec = spec, ok = false, why = "no placement" }
else
bot:wait(30)
bot:clearDialogue()
bot:progress()
local start = bot:frames()
local hops = 0
local origSay = bot.say
-- Count hops without threading a counter through the core.
bot.say = function(self, ...)
local line = table.concat({ ... }, " ")
if type((...)) == "string" and (...):match("^hop ") then
hops = hops + 1
end
return origSay(self, ...)
end
local ok, res = pcall(function() return bot:travelTo(to, region) end)
bot.say = origSay
local spent = bot:frames() - start
if not ok then
results[#results + 1] = { spec = spec, ok = false, frames = spent,
hops = hops,
why = (type(res) == "table" and res.why)
or tostring(res) }
else
results[#results + 1] = { spec = spec, ok = res, frames = spent,
hops = hops,
why = res and "" or "no route",
landed = tostring(A.mapId(game)) }
end
bot:progress()
if spent > budget then
print("[probe] budget spent, stopping")
break
end
end
end
end
print("")
print("================ travel probe ================")
local passed = 0
for _, r in ipairs(results) do
if r.ok then passed = passed + 1 end
print(("%-4s %-46s %7s frames %2s hops %s")
:format(r.ok and "ok" or "FAIL", r.spec, tostring(r.frames or "-"),
tostring(r.hops or "-"),
r.ok and "" or ("%s (on %s)"):format(tostring(r.why),
tostring(r.landed))))
end
print(("%d/%d reachable by walking"):format(passed, #results))
print("==============================================")
end
+103
View File
@@ -0,0 +1,103 @@
-- "The guy who tells you about UNOWN never comes out." The whole chain, live.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_unown_scientist.lua love .
--
-- RuinsOfAlphOutsideScientistCallback (maps/RuinsOfAlphOutside.asm:22) is a
-- MAPCALLBACK_OBJECTS with THREE gates, and the scientist only appears when all
-- three answer:
--
-- checkflag ENGINE_UNOWN_DEX -- must still be CLEAR
-- checkevent EVENT_MADE_UNOWN_APPEAR_IN_RUINS
-- readvar VAR_UNOWNCOUNT / ifgreater 2
--
-- The middle one is set by RuinsOfAlphInnerChamberStrangePresenceScript
-- (maps/RuinsOfAlphInnerChamber.asm:20), which is the `sdefer` on
-- SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE -- the scene a solved chamber
-- puzzle writes with `setmapscene` (maps/RuinsOfAlphKabutoChamber.asm:36). The
-- last is CountUnown over wUnownDex, which only a caught FORM grows.
--
-- The run walks all three and prints which gate is standing, so a failure names
-- its own cause instead of "he is not there". Shots in /tmp/gold-unown.
local U = require("tests.drivers.util")
local Unown = require("src.core.gen2.Unown")
local SCIENTIST = 2 -- def.objects index on RUINS_OF_ALPH_OUTSIDE
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-unown"
local function tap(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 4)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local function standing(index)
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.index == index then return npc end
end
return nil
end
-- Gate 1: he is NOT there before any of it.
world:setMap("RUINS_OF_ALPH_OUTSIDE", 11, 16, "up")
U.wait(20)
print("[driver] scientist before the chain: " .. tostring(standing(SCIENTIST) ~= nil))
U.shot(game, out .. "/00-before.png")
local flag = world.map.def.objects[SCIENTIST].eventFlag
assert(not standing(SCIENTIST), "the scientist is out before the puzzle")
-- The puzzle's own `setmapscene RUINS_OF_ALPH_INNER_CHAMBER,
-- SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE`. Solving the sliding
-- panels is a UI, not a script, so stand in for that one command only.
world.mapScenes["RUINS_OF_ALPH_INNER_CHAMBER"] = 1
-- Gate 2: walking into the inner chamber must run the strange-presence
-- script and set EVENT_MADE_UNOWN_APPEAR_IN_RUINS.
world:setMap("RUINS_OF_ALPH_INNER_CHAMBER", 10, 20, "up")
-- The scene's `sdefer` only fires on the first settled World:step after the
-- load, so give it a few frames before the "is it still running" loop -- a
-- busy() test on frame one reads "already finished".
U.wait(30)
for _ = 1, 300 do
if not world:busy() then break end
tap("a", 2)
end
U.wait(20)
U.shot(game, out .. "/01-inner-chamber.png")
local madeAppear = world.events:get(46)
print("[driver] EVENT_MADE_UNOWN_APPEAR_IN_RUINS: " .. tostring(madeAppear))
print("[driver] inner chamber scene is now " .. tostring(world.mapScenes["RUINS_OF_ALPH_INNER_CHAMBER"]))
assert(madeAppear,
"the strange-presence scene never set EVENT_MADE_UNOWN_APPEAR_IN_RUINS")
-- Gate 3: three distinct Unown forms, the way AddPartyMon's
-- `.registerunowndex` grows wUnownDex.
for _, letter in ipairs({ "A", "B", "C" }) do
Unown.updateDex(game.save, letter)
end
print("[driver] VAR_UNOWNCOUNT is now " .. tostring(Unown.count(game.save)))
assert(Unown.count(game.save) == 3, "wUnownDex did not take three forms")
world:setMap("RUINS_OF_ALPH_OUTSIDE", 11, 16, "up")
U.wait(20)
local npc = standing(SCIENTIST)
print(("[driver] scientist after the chain: %s (his flag %s is %s)")
:format(tostring(npc ~= nil), tostring(flag),
tostring(world.events:get(flag))))
U.shot(game, out .. "/02-scientist.png")
assert(npc, "RuinsOfAlphOutsideScientistCallback never appeared the scientist")
-- And he must have something to say: the scene id the callback set is what
-- his walk-you-to-the-lab script hangs off.
print("[driver] outside scene is now "
.. tostring(world.mapScenes["RUINS_OF_ALPH_OUTSIDE"]))
print("[driver] PASS gold Unown scientist in " .. out)
love.event.quit()
end
@@ -0,0 +1,87 @@
-- `variablesprite` mid-script must repaint an object, not replace it.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_variablesprite_identity.lua love .
--
-- Two of the four map scripts that run `variablesprite` do it with the object
-- standing right there, mid-conversation, and with the VM holding a reference
-- to it as LAST_TALKED:
--
-- LassAliceScript (maps/FuchsiaGym.asm:61-66) is
-- `applymovement FUCHSIAGYM_FUCHSIA_GYM_1, Movement_NinjaSpin / faceplayer /
-- variablesprite SPRITE_FUCHSIA_GYM_1, SPRITE_LASS / special
-- LoadUsedSpritesGFX / faceplayer` -- the ninja spins, unmasks, and the very
-- next command turns the SAME object back to the player;
-- CopycatsHouse2F.asm:23-48 does the same for the Copycat.
--
-- On the cart nothing about the object struct moves: Script_variablesprite
-- writes ONE byte of wVariableSprites (scripting.asm:869) and LoadUsedSpritesGFX
-- reloads the tiles behind it. The object keeps its coordinates, its facing,
-- its FROZEN_F and its place as wLastTalked.
--
-- So the port must keep the same NPC table. Building a new one strands
-- World.talkNpc, .trainerNpc, .followState and any live moveState on an object
-- that is no longer on the map, and drops the object back to its map-def home
-- cell and default facing in the middle of the scene.
local U = require("tests.drivers.util")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Route 36's Sudowoodo is the port's one live object on a SPRITE_VARS byte
-- outside Kanto (maps/Route36.asm:486, SPRITE_WEIRD_TREE = slot 4).
world:setMap("ROUTE_36", 34, 9, "right")
U.wait(20)
local tree, treeId
for _, npc in ipairs(world.npcs) do
if npc.def and npc.def.sprite == 0xf0 + 4 then
tree, treeId = npc, (npc.def.index or 0) + 1
break
end
end
assert(tree, "no SPRITE_WEIRD_TREE object on ROUTE_36")
-- The state a mid-script `variablesprite` has to survive: the object is the
-- one being talked to, it has been turned, and it has been frozen.
world.talkNpc = tree
world.trainerNpc = tree
tree.facing = "left"
tree.frozen = true
local beforeX, beforeY = tree.cellX, tree.cellY
-- `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN`, the same slot write
-- WateredWeirdTreeScript makes (maps/Route36.asm:58).
world:setVariableSprite(4, 38)
U.wait(2)
local after = world:objectEntity(treeId)
print(("[driver] object identity kept: %s"):format(tostring(after == tree)))
print(("[driver] talkNpc still on the map: %s")
:format(tostring(after == world.talkNpc)))
print(("[driver] facing %s -> %s, frozen %s -> %s, cell (%s,%s) -> (%s,%s)")
:format(tostring(tree.facing), tostring(after and after.facing),
tostring(tree.frozen), tostring(after and after.frozen),
tostring(beforeX), tostring(beforeY),
tostring(after and after.cellX), tostring(after and after.cellY)))
print(("[driver] sheet now %s (SPRITE_TWIN wanted)")
:format(tostring(after and after.spriteDef and after.spriteDef.id)))
assert(after, "the object vanished from the map entirely")
assert(after.spriteDef and after.spriteDef.id == "SPRITE_TWIN",
"the slot write did not repaint the object: it is "
.. tostring(after.spriteDef and after.spriteDef.id))
assert(after == tree,
"variablesprite REPLACED the object -- World.talkNpc / .trainerNpc and any "
.. "live movement now point at an NPC that is no longer on the map")
assert(after.facing == "left",
"the object lost the facing a `faceplayer` had just given it: "
.. tostring(after.facing))
assert(after.frozen == true, "the object came back unfrozen mid-script")
assert(after.cellX == beforeX and after.cellY == beforeY,
"the object moved on a slot write")
print("[driver] PASS gold variablesprite keeps the object")
love.event.quit()
end
+125
View File
@@ -0,0 +1,125 @@
-- INDEPENDENT VERIFICATION of the incoming-call caller box
-- (src/ui/gen2/CallerBox.lua, Phone_TextboxWithName at pokegold
-- engine/phone/phone.asm:582). tests/drivers/gold_phone_caller_box.lua takes
-- the random-call route; this one goes at the same seam from the other side:
--
-- * MOM'S route. MomTriesToBuySomething (engine/events/mom_phone.asm) ends
-- `farsjump Script_ReceivePhoneCall` with an INLINE page list and
-- wCurCaller = PHONE_MOM, so its rows are built by the same
-- src/core/gen2/PhoneRing.lua script() as a random call but the caller is
-- a NON-trainer: GetCallerClassAndName stops at the colon and there is no
-- class row (:635-666). The box has to name MOM and print nothing at
-- (6,2).
-- * IDEMPOTENCE. Script_ReceivePhoneCall rings TWICE (RingTwice_StartCall
-- is `call .Ring` falling into .Ring, :458-469), so the push runs twice
-- and exactly one box may ever be on the stack.
-- * NO STALENESS. A second call after the first must name the SECOND
-- caller, which is the check that catches a box cached anywhere across
-- calls.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_vf_caller_box2.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-vf-callerbox (default)
local U = require("tests.drivers.util")
local Phone = require("src.core.gen2.Phone")
local PhoneRing = require("src.core.gen2.PhoneRing")
local CALLER_BOX = "gen2CallerBox"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-vf-callerbox"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[vf-box] ok " .. label)
else
failures = failures + 1
print("[vf-box] FAIL " .. label .. " " .. tostring(detail))
end
end
-- Every caller box on the stack, in stack order.
local function boxes()
local found = {}
for index, state in ipairs(game.stack.states or {}) do
if state[CALLER_BOX] then found[#found + 1] = { index = index, state = state } end
end
return found
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local vm = world.vm
assert(vm, "no script VM")
-- Runs one call to the end, watching the box the whole way. `pages` is the
-- caller's own script as an inline row list, which is exactly the shape
-- World:momTriesToBuy hands PhoneRing.script.
local function runCall(tag, contact, name, className, pages)
vm.curPhoneCaller = contact
local rows = PhoneRing.script({ scriptKey = pages }, name, className)
assert(vm:start(rows), tag .. ": vm refused the call rows")
-- The first ring pushes the box; the ring page only arrives after the
-- second pass. Watch every frame in between so a duplicate pushed by the
-- second RingTwice_StartCall cannot be missed by a coarse sample.
local maxBoxes, sawBox = 0, false
for _ = 1, 400 do
local n = #boxes()
if n > maxBoxes then maxBoxes = n end
if n > 0 then sawBox = true end
if #game.stack.states > 1 and sawBox then break end
U.wait(1)
end
U.wait(30)
local live = boxes()
ok(tag .. ": exactly one caller box is up", #live == 1, #live)
ok(tag .. ": and both rings only ever put up one", maxBoxes <= 1, maxBoxes)
local box = live[1] and live[1].state
ok(tag .. ": it names the caller", box and box.name == name,
box and box.name)
ok(tag .. ": and carries the class the cart prints at (6,2)",
box and box.className == className,
box and tostring(box.className))
ok(tag .. ": it is UNDER the call's text page",
live[1] and live[1].index < #game.stack.states,
live[1] and (live[1].index .. "/" .. #game.stack.states))
ok(tag .. ": and is transparent, so the overworld still draws",
box and box.isOpaque == false, box and tostring(box.isOpaque))
ok(tag .. ": no update, so it cannot steal the fixed step",
box and box.update == nil)
U.shot(game, out .. "/" .. tag .. ".png")
for _ = 1, 400 do
if not world:busy() then break end
U.tap(game, "a")
U.wait(4)
end
ok(tag .. ": the call ran to the end", not world:busy())
ok(tag .. ": and the box came down with it", #boxes() == 0, #boxes())
end
-- MOM: a non-trainer caller, so no class row.
runCall("mom", Phone.PHONECONTACT_MOM,
Phone.NON_TRAINER_NAMES[Phone.PHONECONTACT_MOM], nil,
{ { op = "rawtext", text = "…MOM: Hi!" },
{ op = "rawtext", text = "…MOM: Bye!" },
{ op = "end" } })
-- A trainer caller straight after, to prove nothing is cached between calls.
local trainers = game.data and game.data.trainers
local joeyName, joeyClass = Phone.contactName(15, trainers)
runCall("trainer", 15, joeyName, joeyClass,
{ { op = "rawtext", text = "…JOEY: Yo!" }, { op = "end" } })
U.wait(10)
U.shot(game, out .. "/after.png")
ok("nothing left on the stack over the overworld",
#game.stack.states == 0, #game.stack.states)
print(failures == 0 and "PASS gold_vf_caller_box2"
or ("FAIL gold_vf_caller_box2 (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+105
View File
@@ -0,0 +1,105 @@
-- INDEPENDENT VERIFICATION of the Gold on-screen pad, taken from the angle a
-- phone actually uses it: the BOOT CINEMA, through LOVE's own callbacks.
--
-- tests/drivers/gold_touch_controls.lua drives game:touchpressed directly and
-- only ever with the overworld up. That leaves two things unproven, and both
-- are the difference between "a mobile player can start Gold" and "a mobile
-- player is stuck on the title screen":
--
-- 1. love.touchpressed -> main.lua -> Game2:touchpressed is the REAL route
-- (main.lua:672-720 picks the service owner up out of its `Game` local,
-- which is the Game2 instance for a Gold boot -- main.lua:247). A pad
-- wired only where a driver reaches it would still be dead on a phone.
-- 2. Game2:drawHud is called on every return path of Game2:draw, including
-- the pre-world cinema, and TouchControls:init happens in Game2:load --
-- before the copyright splash -- so the pad must be up and pressable on
-- the title screen, which is the first thing that ever asks for a button.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=1 POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_DRIVER=tests/drivers/gold_vf_touch_boot.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-vf-touchboot (default)
local U = require("tests.drivers.util")
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-vf-touchboot"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[vf-touch] ok " .. label)
else
failures = failures + 1
print("[vf-touch] FAIL " .. label .. " " .. tostring(detail))
end
end
-- A press the way a finger delivers one: through the global LOVE callback,
-- not through the game object.
local function finger(id, x, y, frames)
love.touchpressed(id, x, y, 0, 0, 1)
U.wait(frames or 6)
love.touchreleased(id, x, y, 0, 0, 1)
U.wait(4)
end
U.wait(20)
ok("the pad is up before the world exists", TouchControls:visible(),
tostring(TouchControls.active))
ok("boot cinema really is running (no world yet)", game.world == nil,
game.phase)
local L = TouchControls:layout()
assert(L and L.a and L.start, "no pad layout")
-- Walk the cinema with the pad alone: copyright -> GameFreak -> intro ->
-- title -> main menu. Nothing but the overlay presses a button here.
local seen, lastTop = {}, nil
local reachedMenu = false
for _ = 1, 90 do
local top = game.stack:top()
if top ~= lastTop then
lastTop = top
seen[#seen + 1] = top
end
if game.world or (game.phase == "boot" and #seen >= 4) then
reachedMenu = true
break
end
finger("boot", L.a.cx, L.a.cy, 8)
end
ok("the pad alone walked the boot cinema forward", #seen >= 3,
#seen .. " screens")
ok("and got past the title screen without a keyboard", reachedMenu,
tostring(game.phase))
U.shot(game, out .. "/01-boot-pad.png")
-- The pad is still the thing pressing: hold A down through the real
-- callbacks and check Input sees it under the overlay's own source.
love.touchpressed("hold", L.a.cx, L.a.cy, 0, 0, 1)
U.wait(2)
ok("a finger on A during the cinema reaches Input", Input:isDown("a"))
ok("under the overlay's source, not a keyboard alias",
Input:isTouchDown("a"))
U.shot(game, out .. "/02-held.png")
love.touchreleased("hold", L.a.cx, L.a.cy, 0, 0, 1)
U.wait(2)
ok("and lifting it releases", not Input:isDown("a"))
-- Focus loss with a finger down: LOVE has no touchcancelled, so without the
-- reset in Game2:focus a held overlay button is stranded forever.
love.touchpressed("stranded", L.b.cx, L.b.cy, 0, 0, 1)
U.wait(2)
ok("B is held before focus is taken away", Input:isDown("b"))
game:focus(false)
U.wait(2)
ok("losing focus frees the held pad button", not Input:isDown("b"))
game:focus(true)
U.wait(2)
print(failures == 0 and "PASS gold_vf_touch_boot"
or ("FAIL gold_vf_touch_boot (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+118
View File
@@ -0,0 +1,118 @@
-- INDEPENDENT VERIFICATION that the Gold on-screen pad actually PLAYS the
-- game, not just that it sets a flag in src/core/Input.lua.
--
-- The three presses a phone player cannot do without, each read at a different
-- place in Game2's fixed step:
--
-- d-pad -> World:pollInput, the walk itself
-- START -> the wasPressed("start") arm that opens the start menu
-- SELECT -> the wasPressed("select") arm, UseRegisteredItem
-- (engine/overworld/select_menu.asm)
--
-- START and SELECT are edge reads, so this is also the check that the
-- overlay's press survives Input:step's per-tick edge promotion -- a hold that
-- never produces an edge would leave the menus unreachable even though
-- Input:isDown said the button was down. Everything goes through the global
-- LOVE callbacks, the way a finger does.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=1 \
-- POKEPORT_DRIVER=tests/drivers/gold_vf_touch_play.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-vf-touchplay (default)
local U = require("tests.drivers.util")
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-vf-touchplay"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[vf-play] ok " .. label)
else
failures = failures + 1
print("[vf-play] FAIL " .. label .. " " .. tostring(detail))
end
end
local function finger(id, x, y, frames)
love.touchpressed(id, x, y, 0, 0, 1)
U.wait(frames or 6)
love.touchreleased(id, x, y, 0, 0, 1)
U.wait(6)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
ok("the pad is up over the overworld", TouchControls:visible())
local L = TouchControls:layout()
-- WALK. Hold a d-pad direction long enough for OWPlayerInput to take the
-- step, and check the player actually moved a cell.
local x0, y0 = world.player.cellX, world.player.cellY
local moved, tries = false, 0
for _, dir in ipairs({ "down", "up", "left", "right" }) do
if moved then break end
tries = tries + 1
local dx = (dir == "left" and -0.4) or (dir == "right" and 0.4) or 0
local dy = (dir == "up" and -0.4) or (dir == "down" and 0.4) or 0
love.touchpressed("walk", L.dpad.cx + L.dpad.w * dx,
L.dpad.cy + L.dpad.w * dy, 0, 0, 1)
U.wait(40)
love.touchreleased("walk", L.dpad.cx + L.dpad.w * dx,
L.dpad.cy + L.dpad.w * dy, 0, 0, 1)
U.wait(10)
if world.player.cellX ~= x0 or world.player.cellY ~= y0 then moved = true end
end
ok("a finger on the d-pad walks the player", moved,
("%d,%d -> %d,%d after %d directions"):format(x0, y0,
world.player.cellX, world.player.cellY, tries))
U.shot(game, out .. "/01-walked.png")
-- START. The overworld arm is an edge read (input:wasPressed), so a hold
-- that never promotes to an edge would leave the menu unreachable.
local before = #game.stack.states
finger("start", L.start.cx, L.start.cy, 8)
U.wait(12)
local menu = game.stack:top()
ok("a finger on START opens the start menu",
#game.stack.states > before and menu ~= nil,
#game.stack.states .. " states")
U.shot(game, out .. "/02-start-menu.png")
-- B backs out of it, so the pad can leave the menu it just opened.
finger("b", L.b.cx, L.b.cy, 8)
U.wait(12)
ok("and a finger on B backs out of it",
#game.stack.states == before, #game.stack.states)
-- SELECT. Nothing is registered, so UseRegisteredItem takes CantUseItem's
-- "nothing registered" arm -- which is still a text box, i.e. proof the
-- press reached the arm rather than quitting the process (the old
-- Game2:gamepadpressed answered `back` with love.event.quit()).
finger("select", L.select.cx, L.select.cy, 8)
U.wait(20)
ok("a finger on SELECT reaches UseRegisteredItem",
#game.stack.states > before or world:busy(),
#game.stack.states .. " states, busy=" .. tostring(world:busy()))
U.shot(game, out .. "/03-select.png")
-- And the controller's own SELECT, which is what bug 3 was about: `back` is
-- SDL's name for the DualSense CREATE button (the shipped controller DB row
-- "PS5 Controller,...,back:b8,...") and GamepadMap binds it to GB SELECT.
-- The process must still be alive after it.
love.gamepadpressed(nil, "back")
U.wait(2)
ok("a controller `back` presses GB SELECT instead of quitting",
Input:isDown("select"))
love.gamepadreleased(nil, "back")
U.wait(2)
ok("and releases it", not Input:isDown("select"))
ok("the process is still running", love.window ~= nil)
print(failures == 0 and "PASS gold_vf_touch_play"
or ("FAIL gold_vf_touch_play (%d)"):format(failures))
love.event.quit(failures == 0 and 0 or 1)
end
+116
View File
@@ -0,0 +1,116 @@
-- Smoke: Gold bedroom → downstairs → outside → house → carpet out → Route 29.
--
-- A New Game now starts at SPAWN_HOME (PLAYERS_HOUSE_2F 3,3), the way
-- engine/menus/intro_menu.asm NewGame does, so the walk begins upstairs: the
-- stairs warp is at (7,0) on 2F and the front door at (6,7)/(7,7) on 1F.
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_walk_smoke.lua love .
return function(game)
local function wait(frames)
for _ = 1, frames do coroutine.yield() end
end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
local function hold(dir, frames)
clearDirs()
for _ = 1, frames do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
clearDirs()
end
-- Entering the house runs MeetMomScript, and a driver that only holds a
-- direction would sit behind its text boxes forever. Tap A until the world
-- accepts input again. That script is long -- an approach walk, ten text
-- boxes and three yes/no prompts, ~1300 frames at this tap rate -- so the
-- budget has to be generous or the run looks like a hang.
local function clearDialogue()
for _ = 1, 1200 do
if not game.world:busy() then return end
table.insert(game.input.pressQueue, "a")
coroutine.yield()
coroutine.yield()
end
end
local function mapId()
return game.world and game.world.map and game.world.map.id
end
local function pos()
local p = game.world.player
return p.cellX, p.cellY
end
wait(45)
assert(mapId() == "PLAYERS_HOUSE_2F", "boot map " .. tostring(mapId()))
-- The bedroom is where a New Game lands, but this driver is about warps and
-- the Route 29 edge crossing, and the indoor route down two floors is a
-- fragile way to get to them. Drop straight outside instead: the door and
-- carpet warps below are the ones under test.
game.world:setMap("NEW_BARK_TOWN", 13, 6, "down")
wait(15)
assert(mapId() == "NEW_BARK_TOWN",
("after setMap: %s @ (%d,%d)"):format(tostring(mapId()), pos()))
-- Walk back into the player's house door (one cell north of the doorstep).
hold("up", 40)
wait(15)
assert(mapId() == "PLAYERS_HOUSE_1F",
"after door: " .. tostring(mapId()))
clearDialogue()
-- Entry faces up and keeps walking off the carpet into the room; hold
-- down to step back onto the carpet and warp out.
hold("down", 60)
wait(15)
-- Walking in can trip another coord script; clear it and try the carpet
-- again before deciding the exit is broken.
clearDialogue()
if mapId() ~= "NEW_BARK_TOWN" then
hold("up", 24)
clearDialogue()
hold("down", 48)
wait(15)
end
assert(mapId() == "NEW_BARK_TOWN",
("after carpet: %s @ (%d,%d)"):format(tostring(mapId()), pos()))
-- New Bark's west exit is gated: at scene SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU
-- the coord events at (1,8)/(1,9) run the teacher's "It's dangerous to go out
-- without a POKéMON!" script and walk the player back, so a party-less save
-- can never reach Route 29. ElmsLab.asm sets the town to SCENE_NEWBARKTOWN_
-- NOOP once the errand starts; do the same rather than fight the guard.
game.world.mapScenes["NEW_BARK_TOWN"] = 1
-- West→Route 29 only has a walkable landing at y=9: row 8 has the tree at
-- x=8 and row 10 is wall west of x=6. Re-square onto y=9 every pass rather
-- than only after a bump, and bound the walk -- a wandering townsfolk can
-- stand in the way for a step or two, and an unbounded retry loop turns that
-- into a run that never ends.
local x, y = pos()
for _ = 1, 60 do
if mapId() == "ROUTE_29" then break end
assert(mapId() == "NEW_BARK_TOWN",
"unexpected map " .. tostring(mapId()))
if y < 9 then
hold("down", 24)
elseif y > 9 then
hold("up", 24)
else
hold("left", 24)
end
x, y = pos()
end
assert(mapId() == "ROUTE_29",
("after west edge: %s @ (%d,%d)"):format(tostring(mapId()), pos()))
print("[driver] PASS gold walk house + Route 29")
end
+92
View File
@@ -0,0 +1,92 @@
-- Assertion driver: the bedroom wall radio after the starter, played through
-- the real bg event -> jumpstd Radio1Script -> `special MapRadio` chain.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_wall_radio.lua love .
--
-- tests/gen2_map_radio_test.lua drives the screen's own logic; what it cannot
-- see is the dispatch: a real A press on the radio tile, the std script's
-- setval/special pair, the screen landing on the real stack, and the music
-- surviving the exit (ExitPokegearRadio_HandleMusic). Each check here does it
-- the way a player would, twice, because the replay is half the point: the
-- radio must answer every interaction, not just the first.
local U = require("tests.drivers.util")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local Music = require("src.core.Music")
-- Past the starter: PlayersHouseRadioScript's `checkevent
-- EVENT_GOT_A_POKEMON_FROM_ELM` picks the .NormalRadio arm, which is
-- `jumpstd Radio1Script`.
world.events:set(26, true)
-- The radio bg event sits at (3,1) in PLAYERS_HOUSE_2F; (3,2) facing up
-- reads it.
assert(world:setMap("PLAYERS_HOUSE_2F", 3, 2, "up"),
"setMap failed for PLAYERS_HOUSE_2F")
U.wait(10)
local function radioScreen()
local top = game.stack:top()
return (top and top.screenId == "Gen2MapRadio") and top or nil
end
local function listenOnce(round)
U.tap(game, "a")
local screen
for _ = 1, 120 do
screen = radioScreen()
if screen then break end
U.wait(1)
end
assert(screen, round .. ": A on the radio did not open the wall radio")
assert(screen.station, round .. ": no station resolved")
-- PlayRadio holds 100 frames with the station name up, then the show
-- starts its channel song.
U.wait(110)
for _ = 1, 240 do
if screen.radio.music then break end
U.wait(1)
end
assert(screen.radio.music, round .. ": the show never started its song")
local song = screen.radio.music
assert(Music.current() == song,
("%s: playing %s, want %s"):format(round,
tostring(Music.current()), tostring(song)))
-- A closes it, and the song KEEPS PLAYING as the map music
-- (RadioMusicRestartDE wrote it into wMapMusic).
U.tap(game, "a")
for _ = 1, 60 do
if not radioScreen() then break end
U.wait(1)
end
assert(not radioScreen(), round .. ": A did not close the radio")
U.wait(5)
assert(Music.current() == song,
round .. ": the song stopped when the radio closed")
assert(Music.mapSong() == song,
round .. ": the song did not become the map music")
U.log(round .. ": radio played " .. song .. " and it persists")
return song
end
listenOnce("first listen")
-- Back out, talk again: the radio must play again.
local song = listenOnce("second listen")
-- A map change is what replaces the song, exactly as a new map's
-- PlayMapMusic would.
assert(world:setMap("NEW_BARK_TOWN", 8, 8, "down"),
"setMap failed for NEW_BARK_TOWN")
U.wait(10)
assert(Music.current() ~= song,
"leaving the house did not restore the map's own music")
U.log("map change replaced the radio song with "
.. tostring(Music.current()))
print("[driver] PASS gold wall radio")
love.event.quit()
end
+124
View File
@@ -0,0 +1,124 @@
-- The door warp, end to end, on the map a player meets it on first.
--
-- Walking onto a warp tile is PLAYEREVENT_WARP -> WarpToNewMapScript
-- (engine/overworld/events.asm), which is `warpsound` then
-- `newloadmap MAPSETUP_DOOR`. MapSetupScript_Door opens on FadeOutToWhite and
-- falls through into _Train, whose tail is FadeInFromWhite, so the load sits in
-- the MIDDLE of the setup script. This driver asserts the three things that
-- were wrong, and shoots the fade so a human can see it:
--
-- 1. the warp makes a sound, picked off the tile the player stands on;
-- 2. the screen fades out and back in around the load, holding input for the
-- sixteen frames it runs (four steps of ConvertTimePals*HL, DelayFrames 2
-- apart, per half);
-- 3. the player lands on the doormat at (4,11) and the lab's own scene script
-- walks them the nine steps of ElmsLab_WalkUpToElmMovement to (4,2) facing
-- right -- NOT to (4,1), which is where a free step stolen by a still-held
-- direction used to leave them.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_warp_scene.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Shots land in /tmp/gold-warp.
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-warp"
return function(game)
local w = game.world
local fails = 0
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function ok(cond, msg)
if cond then
print("[warp] ok " .. msg)
else
fails = fails + 1
print("[warp] FAIL " .. msg)
end
return cond
end
local function shot(name)
game.capturePath = SHOT_DIR .. "/" .. name .. ".png"
coroutine.yield()
end
local function cell()
local p = w.player
return p.cellX, p.cellY, p.facing
end
local function holdInto(dir, limit)
local from = w.map.id
local levels, shots = {}, 0
for _ = 1, limit do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
if w.mapSetup then
levels[#levels + 1] = w.fadeLevel
shots = shots + 1
game.capturePath = ("%s/fade-%02d.png"):format(SHOT_DIR, shots)
end
coroutine.yield()
if w.map.id ~= from and not w.mapSetup then break end
end
game.input.pressQueue = {}
game.input.state[dir] = false
game.input.sources[dir] = nil
return levels
end
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
wait(45)
-- The teacher's coord event at the west edge gates a party-less save; the
-- errand script clears it, and this driver is about the door, not the guard.
w.mapScenes.NEW_BARK_TOWN = 1
w:setMap("NEW_BARK_TOWN", 6, 5, "up")
wait(15)
-- GetWarpSFX reads the tile the player is STANDING on, so the sound belongs to
-- the doorway they walk into, not to the room they arrive in.
local sfx = {}
local realPlaySfx = w.playSfx
w.playSfx = function(self, id) sfx[#sfx + 1] = id realPlaySfx(self, id) end
local levels = holdInto("up", 180)
ok(w.map.id == "ELMS_LAB", "the door warps into the lab (" .. w.map.id .. ")")
ok(#sfx > 0, "and it makes a sound (" .. #sfx .. " sfx)")
-- Four rising levels, four falling, with the solid frame in between.
ok(#levels >= 12,
"the fade ran for " .. #levels .. " frames (sixteen is the full chain)")
local peak = 0
for _, v in ipairs(levels) do if (v or 0) > peak then peak = v end end
ok(peak == 1, "and reached a solid sheet (peak " .. tostring(peak) .. ")")
ok(w.fade == nil, "which is gone by the time control comes back")
local x, y, facing = cell()
ok(x == 4 and y == 11,
("lands on the doormat at (4,11), got (%d,%d)"):format(x, y))
ok(facing == "up",
"still facing up: the mat inside is a carpet, not a CheckWarpFacingDown "
.. "tile (got " .. tostring(facing) .. ")")
shot("arrive")
-- ElmsLab_WalkUpToElmMovement: nine `step UP` then `turn_head RIGHT`.
local idle = 0
for _ = 1, 900 do
if w:busy() then idle = 0 else idle = idle + 1 end
if idle > 40 then break end
wait(3)
end
x, y, facing = cell()
ok(x == 4 and y == 2 and facing == "right",
("the entry scene ends at (4,2) facing right, got (%d,%d) %s")
:format(x, y, tostring(facing)))
shot("met-elm")
if fails > 0 then
error(("gold warp scene: %d assertion(s) failed"):format(fails))
end
print("[driver] PASS gold door warp: sound, fade, and the lab entry walk")
end
+168
View File
@@ -0,0 +1,168 @@
-- Assertion driver: SURF and WHIRLPOOL, done the way a player does them, on
-- real Gold maps. It PASSES or it errors.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_water_moves.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-water (default)
--
-- tests/gen2_world_test.lua checks the pure halves (FieldMoves' badge gate,
-- the tile tests, the block replacement tables). What it cannot check is the
-- part that is all wiring: that walking into the sea puts you ON it, that the
-- player sprite becomes the Lapras, that a whirlpool block really leaves the
-- map's block buffer, and that the water behind it is passable afterwards.
--
-- Both routes IN are driven, because they are different code on the cart and
-- were different code here: TrySurfOW / TryWhirlpoolOW (walk into it, answer
-- the prompt) runs on the spot through CallScript, and the PACK / party-menu
-- route queues a script that only runs once the menus are gone.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local Mon = require("src.battle.gen2.Mon")
-- Cells read out of the cache rather than remembered: Cherrygrove's beach is
-- the first stretch of sea a player can reach, and Route 41's whirlpools are
-- the ones between Olivine and Cianwood.
local BEACH = { map = "CHERRYGROVE_CITY", x = 10, y = 9 } -- faces water below
local WHIRL = { map = "ROUTE_41", x = 22, y = 11 } -- faces (22,12)
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-water"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local function tap(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
-- Answer whatever text box or yes/no the field move puts up, until the world
-- is idle again. A field move is a script, so `busy` is the honest "is it
-- still going" and mashing A is what a player does.
local function settle(limit)
for _ = 1, (limit or 60) do
if not world:busy() and not world.fieldMove then return true end
tap("a", 4)
end
return not world:busy() and not world.fieldMove
end
-- SURF is FOGBADGE, WHIRLPOOL is GLACIERBADGE (FieldMoves.BADGE). Give
-- both, so what is under test is the move and not the gate -- the gate has
-- its own checks in gen2_world_test.
local badges = game.save.player.badges or {}
game.save.player.badges = badges
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end
assert(FieldMoves.hasBadge(game.save, FieldMoves.BADGE.SURF), "FOGBADGE")
assert(FieldMoves.hasBadge(game.save, FieldMoves.BADGE.WHIRLPOOL), "GLACIER")
local swimmer = Mon.new(game.data, "LAPRAS", 30,
{ moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } })
assert(swimmer, "could not build a LAPRAS")
game.save.party = { swimmer }
-- ---- SURF, by walking into the sea -------------------------------------
do
world:setMap(BEACH.map, BEACH.x, BEACH.y, "down")
U.wait(10)
local ctx = world:fieldContext()
assert(Permissions.isWater(ctx.facingColl),
("%s (%d,%d) is not facing water any more -- re-import moved the beach")
:format(BEACH.map, BEACH.x, BEACH.y))
assert(not FieldMoves.isSurfing(world.playerState), "not surfing yet")
U.shot(game, out .. "/00-beach.png")
assert(world:trySurfOW(), "TrySurfOW refused a water tile with the badge")
assert(settle(), "the surf script never finished")
assert(FieldMoves.isSurfing(world.playerState),
"SURF ran and the player is still on foot")
assert(world.player.cellY > BEACH.y,
("the player did not step onto the water: still at (%d,%d)")
:format(world.player.cellX, world.player.cellY))
local coll = world.map:cellCollision(world.player.cellX, world.player.cellY)
assert(Permissions.isWater(coll),
"the player is surfing on something that is not water")
U.shot(game, out .. "/01-surfing.png")
-- And it is a real state, not a one-step animation: walk further out.
local fromY = world.player.cellY
U.hold(game, "down", 40)
U.wait(20)
assert(world.player.cellY > fromY,
"the player cannot swim once surfing")
assert(FieldMoves.isSurfing(world.playerState),
"the surf state did not survive a step")
U.log(("SURF: walked into the sea at (%d,%d) and swam to (%d,%d)")
:format(BEACH.x, BEACH.y, world.player.cellX, world.player.cellY))
end
-- ---- WHIRLPOOL, from the party menu ------------------------------------
do
-- Route 41 is open sea, so the player arrives already surfing -- which is
-- what the cart does too (wPlayerState survives the warp).
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(WHIRL.map, WHIRL.x, WHIRL.y, "down")
U.wait(10)
local def = world.maps[WHIRL.map]
local ctx = world:fieldContext()
assert(Permissions.isWhirlpool(ctx.facingColl),
("%s (%d,%d) is not facing a whirlpool"):format(
WHIRL.map, WHIRL.x, WHIRL.y))
local index = ctx.facingBlockIndex
local before = def.blocks[index]
local replacement = select(1, FieldMoves.blockReplacement(
FieldMoves.WHIRLPOOL_BLOCKS, ctx.tileset, ctx.facingBlock))
assert(replacement,
"no WhirlpoolBlockPointers row for this tileset/block pair")
assert(before ~= replacement, "the whirlpool is already cleared")
U.shot(game, out .. "/02-whirlpool.png")
-- The party-menu route: the result is QUEUED and only runs once the menus
-- are gone, which is the half that is easy to wire up wrong.
local result = world:useFieldMove("WHIRLPOOL", game.save.party[1])
assert(result and result.ok,
"the party menu refused WHIRLPOOL: " .. tostring(result and result.text))
assert(world.queuedFieldMove, "and it ran on the spot instead of queueing")
assert(world:runQueuedFieldMove(), "the queued move did not start")
assert(settle(), "the whirlpool script never finished")
assert(def.blocks[index] == replacement,
("the whirlpool block did not change: %s, want %s")
:format(tostring(def.blocks[index]), tostring(replacement)))
local after = world.map:cellCollision(ctx.facingX, ctx.facingY)
assert(not Permissions.isWhirlpool(after),
"the block changed but the cell is still a whirlpool")
assert(Permissions.isWalkable(after) or Permissions.isWater(after),
"the cleared whirlpool is not passable")
U.shot(game, out .. "/03-whirlpool-cleared.png")
-- Swim through it, which is the whole point of clearing one.
U.hold(game, "down", 40)
U.wait(20)
assert(world.player.cellY >= ctx.facingY,
("the player could not swim through the cleared whirlpool: (%d,%d)")
:format(world.player.cellX, world.player.cellY))
U.log(("WHIRLPOOL: block %d %s -> %s on %s, and the player swam through")
:format(index, tostring(before), tostring(replacement), WHIRL.map))
-- LoadMapAttributes refills the block buffer from ROM: a whirlpool is back
-- the next time you sail in, exactly like a cut tree.
world:setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(5)
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(WHIRL.map, WHIRL.x, WHIRL.y, "down")
U.wait(5)
assert(def.blocks[index] == before,
"the whirlpool did not come back after a map load")
U.log("and it is back on the next map load, the way the cart refills it")
end
U.log("PASS gold_water_moves in " .. out)
love.event.quit()
end
@@ -0,0 +1,127 @@
-- Current tiles at Tohjo Falls, driven through the real game.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \
-- POKEPORT_GOLD_RESUME=16 \
-- POKEPORT_DRIVER=tests/drivers/gold_waterfall_current_probe.lua love .
--
-- DoPlayerMovement's .CheckTile treats COLL_WATERFALL $33 as a CURRENT tile and
-- forces one DOWN step per frame while the player stands on one, above
-- .CheckTurning and .TryStep -- so the plunge is automatic and the column
-- cannot be climbed by pressing UP. Tohjo Falls' west fall is four cells wide
-- and four tall ((8,8)..(11,11)), with plain water above and below it, which
-- makes it the map the three claims below are about:
--
-- * holding UP from the pool never reaches the ledge above the fall
-- * HM07's own climb (Script_UsedWaterfall) still does
-- * stepping back into the column carries the player down with no input
--
-- Prints one PASS/FAIL line per claim and quits.
local Bot = dofile("tests/drivers/gold/bot.lua")
local A = Bot.adapter
local FieldMoves = require("src.world.gen2.FieldMoves")
local MAP = "TOHJO_FALLS"
local POOL_X, POOL_Y = 11, 12 -- plain water below the fall
local TOP_Y = 7 -- the water above it
local results = {}
local function claim(ok, text)
results[#results + 1] = ok and true or false
print((ok and "[current] PASS " or "[current] FAIL ") .. text)
end
return function(game)
local bot = Bot.new(game)
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local resume = os.getenv("POKEPORT_GOLD_RESUME") or "16"
local ok, err = A.loadCheckpoint(game, resume)
if not ok then
print(("[current] cannot resume %s: %s"):format(resume, tostring(err)))
love.event.quit()
return
end
for _ = 1, 3000 do
if A.ready(game) then break end
bot:wait(1)
end
local world = game.world
world:setMap(MAP, POOL_X, POOL_Y, "up")
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world.noWildEncounters = true
bot:wait(30)
bot:clearDialogue(nil, 4000)
claim(A.mapId(game) == MAP and A.surfing(game),
"surfing in the pool below the west fall")
local map = A.map(game)
claim(map ~= nil and A.isWaterfall(map, POOL_X, POOL_Y - 1),
"the cell above the player is a waterfall tile")
-- ---- the d-pad cannot climb it ------------------------------------------
local highest = select(2, A.pos(game))
for _ = 1, 600 do
A.hold(game, "up")
bot:wait(1)
local _, y = A.pos(game)
if y < highest then highest = y end
end
A.releaseDirs(game)
bot:wait(20)
claim(highest > TOP_Y,
("holding UP for 600 frames never got above the fall (best y=%d)")
:format(highest))
-- ---- HM07 still does -----------------------------------------------------
local px, py = A.pos(game)
if py ~= POOL_Y or px ~= POOL_X then
world:setMap(MAP, POOL_X, POOL_Y, "up")
world:applyPlayerState(FieldMoves.PLAYER_SURF)
bot:wait(20)
end
bot:face("up")
local used = A.useWaterfall(game)
bot:wait(8)
bot:clearDialogue({ "yes" }, 6000)
for _ = 1, 900 do
if not A.moving(game) and not A.busy(game) then break end
bot:wait(1)
end
local _, afterClimb = A.pos(game)
claim(used and afterClimb <= TOP_Y,
("Script_UsedWaterfall still climbs the fall (y=%s)")
:format(tostring(afterClimb)))
-- ---- and the descent rides the current -----------------------------------
local beforeX, beforeY = A.pos(game)
-- Long enough to turn and take the ONE step onto the top of the fall, then
-- the d-pad is let go: everything after this is .CheckTile's doing.
bot:face("down")
A.hold(game, "down")
bot:wait(20)
A.releaseDirs(game)
for _ = 1, 600 do
if not A.moving(game) then
local _, y = A.pos(game)
if y >= POOL_Y then break end
end
bot:wait(1)
end
local endX, endY = A.pos(game)
claim(endY >= POOL_Y,
("one DOWN press carried the player the whole way down (%d,%d -> %d,%d)")
:format(beforeX, beforeY, endX, endY))
local failures = 0
for _, value in ipairs(results) do
if not value then failures = failures + 1 end
end
print(("[current] %d claims, %d failed"):format(#results, failures))
love.event.quit()
end
+62
View File
@@ -0,0 +1,62 @@
-- ZOOM in the Gold overworld: the map resizes, the UI does not.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_zoom_shots.lua love .
--
-- One shot per zoom level with a dialogue box up, and one more with the START
-- menu up. Across the set the map behind the box has to change size and the
-- box itself has to be pixel-for-pixel identical -- that is the two-pass split
-- src/render/Renderer.lua makes for Gen 1 (UI LAYOUT = CENTERED: the world
-- canvas follows Zoom.scale, the UI canvas stays on fitScale), which
-- Game2:drawScene now makes too. Before it did, the text box grew and
-- shrank along with the world.
--
-- Writes to /tmp/gold-zoom (POKEPORT_SHOT_DIR to move it).
local U = require("tests.drivers.util")
local StartMenu = require("src.ui.gen2.StartMenu")
local Zoom = require("src.render.Zoom")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-zoom"
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- Outside, where the survey zoom is worth looking at at all.
if game.world.map.id ~= "NEW_BARK_TOWN" then
game.world:setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(20)
end
local fit = game.world:fitScale()
print(("[driver] fit scale %d"):format(fit))
local function at(offset, name)
Zoom.offset = Zoom.clampOffset(offset, fit)
U.wait(4)
U.shot(game, ("%s/%s.png"):format(out, name))
print(("[driver] %s: offset %d, world x%.2f, ui x%d")
:format(name, Zoom.offset, game.world:zoomScale(), fit))
return Zoom.offset
end
game:say("ZOOM leaves this box alone.")
U.wait(6)
local survey = at(-2, "01-survey")
at(0, "02-fit")
local close = at(2, "03-close")
assert(survey < 0 or close > 0,
"the zoom range collapsed to a single step; nothing to compare")
game.stack:pop()
Zoom.offset = 0
U.wait(4)
game:openStartMenu()
U.wait(10)
assert(getmetatable(game.stack:top()) == StartMenu,
"START menu did not open (top " .. tostring(game.stack:top()) .. ")")
at(-2, "04-startmenu-survey")
at(2, "05-startmenu-close")
Zoom.offset = 0
print("[driver] PASS gold zoom shots in " .. out)
end
+249
View File
@@ -0,0 +1,249 @@
-- Independent verification probe for the Gold battle screen lane.
-- Temporary: attacks the same claims from a different angle than
-- tests/drivers/gold_battle_screen_probe.lua.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/verify_battle_screen.lua \
-- POKEPORT_VPROBE=switch1 POKEPORT_SHOT_DIR=/tmp/verify-battle/v love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/verify-battle/v"
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
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
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
local V = {}
-- Bug 4, the real player route: after the faint, move the cursor to a HEALTHY
-- mon and press A exactly ONCE. The claim under test is that the switch takes
-- on that press; the author's probe only ever proved the refusal LINE.
function V.switch1(game)
local weak = Mon.new(game.data, "CYNDAQUIL", 5)
weak.hp = 1
game.save.party = { weak, Mon.new(game.data, "TOTODILE", 30) }
game.save.inventory = {}
local entry = game.world:trainerParty(36, 1)
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")
local aPresses, listOpens = 0, 0
local realOpen = battle.openParty
battle.openParty = function(self, forced)
if forced then listOpens = listOpens + 1 end
return realOpen(self, forced)
end
local switched, moved = false, false
for _ = 1, 1200 do
if battle.battle.over then break end
local top = game.stack:top()
if top ~= battle then
-- The forced list. Move down to the healthy mon FIRST, then one A.
if not moved then tap(game, "down", 4); moved = true end
aPresses = aPresses + 1
tap(game, "a", 6)
U.wait(20)
if battle.battle.player and (battle.battle.player.hp or 0) > 0
and battle.battle.player.species == "TOTODILE" then
switched = true
break
end
elseif battle.phase == "menu" then
tap(game, "a", 2); U.wait(3); tap(game, "a", 2)
else
tap(game, "a", 2)
end
end
print(("[v] switch1: listOpens=%d aPressesOnList=%d switched=%s active=%s")
:format(listOpens, aPresses, tostring(switched),
tostring(battle.battle.player and battle.battle.player.species)))
U.shot(game, OUT .. "/switch1-after.png")
end
-- Bug 3, the NORMAL save: currentBox is 1, the value every fresh Gold save and
-- every save the engine itself writes carries. If the catch only reached a box
-- because of the clamp, this is where that shows.
function V.box1(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
game.save.currentBox = tonumber(os.getenv("POKEPORT_VBOX") 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")
for _ = 1, 600 do
if not battle.anim then break end
U.wait(1)
end
for _ = 1, 300 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, where = 0, "nowhere"
for i, box in pairs(game.save.boxes or {}) do
total = total + #box
if #box > 0 then where = "box" .. tostring(i) end
end
print(("[v] box1: currentBox=%s party=%d anywhere=%d landedIn=%s boxFilled=%s")
:format(tostring(game.save.currentBox), #game.save.party, total, where,
tostring(battle.battle.boxFilled)))
end
-- The picHidden REGRESSION risk: a trainer whose first mon faints must send its
-- SECOND one out and that mon must be visible. A latch that a send-out fails
-- to clear leaves an invisible opponent for the rest of the battle.
function V.secondmon(game)
game.save.party = { Mon.new(game.data, "TOTODILE", 40) }
game.save.inventory = {}
-- Find a trainer entry that actually carries two mons.
local Trainers = require("src.world.gen2.Trainers")
local entry, size
for class = 1, 60 do
for member = 1, 6 do
local ok, e = pcall(game.world.trainerParty, game.world, class, member)
if ok and e then
local party = Trainers.party(game.data, e)
if party and #party >= 2 then
entry, size = e, #party
e.party = party
break
end
end
end
if entry then break end
end
assert(entry, "no multi-mon trainer found")
print(("[v] secondmon: trainer class=%s members=%d")
:format(tostring(entry.classId or entry.class), size))
local battle = openBattle(game, { trainer = entry })
assert(toMenu(game, battle), "never reached the battle menu")
local sawSecond, hiddenAtSecond, shot = false, nil, 0
for _ = 1, 2000 do
if battle.battle.over then break end
if battle.battle.enemyIndex and battle.battle.enemyIndex > 1
and not sawSecond then
sawSecond = true
-- Let the send-out settle, then look at the latch and shoot it.
U.wait(90)
hiddenAtSecond = battle.picHidden.enemy
U.shot(game, OUT .. "/secondmon-out.png")
end
if battle.phase == "menu" then
tap(game, "a", 2); U.wait(3); tap(game, "a", 2)
else
if shot < 3 and battle.faintSlide then
U.shot(game, ("%s/secondmon-faint-%02d.png"):format(OUT,
battle.faintSlide.frames))
shot = shot + 1
end
tap(game, "a", 2)
end
end
print(("[v] secondmon: sawSecond=%s picHidden.enemy@second=%s outcome=%s")
:format(tostring(sawSecond), tostring(hiddenAtSecond),
tostring(battle.battle.outcome)))
U.shot(game, OUT .. "/secondmon-end.png")
end
-- Bug 2, the half that is cache-side: what the DEFAULT identity's Gold cache
-- actually carries, and what the class key resolves to.
function V.trainerpic(game)
local hud = game.data.gen2MenuGfx and game.data.gen2MenuGfx.battleHud
local pics = hud and hud.trainerPics
local n = 0
local sample
for k in pairs(pics or {}) do n = n + 1; sample = sample or k end
print(("[v] trainerpic: cache trainerPics=%s count=%d sample=%s")
:format(tostring(pics ~= nil), n, tostring(sample)))
local Trainers = require("src.world.gen2.Trainers")
local entry = game.world:trainerParty(36, 1)
print(("[v] trainerpic: lookup(36,1) class=%s classId=%s className=%s")
:format(tostring(entry and entry.class), tostring(entry and entry.classId),
tostring(entry and entry.className)))
entry.party = Trainers.party(game.data, entry)
local battle = openBattle(game, { trainer = entry })
print(("[v] trainerpic: enemyTrainerClass=%s showEnemyTrainer=%s path=%s")
:format(tostring(battle.enemyTrainerClass),
tostring(battle.showEnemyTrainer), tostring(battle.enemyTrainerPath)))
for i = 0, 8 do
U.shot(game, ("%s/trainerpic-%02d.png"):format(OUT, i))
U.wait(10)
end
end
-- The counterfactual to bug 1: a ball that FAILS. BattleAnim_ThrowPokeBall's
-- break-out arm puts the mon back on the field, so a latch set anywhere but on
-- the caught arm would make a wild mon vanish for the rest of the battle.
function V.missball(game)
game.save.party = { Mon.new(game.data, "TOTODILE", 40) }
game.save.inventory = { POKE_BALL = 30 }
local wild = Mon.new(game.data, "ONIX", 40) -- full HP, low catch rate
local battle = openBattle(game, { wild = wild })
assert(toMenu(game, battle), "never reached the battle menu")
local tries, escaped = 0, false
for _ = 1, 20 do
tries = tries + 1
battle:useItem("POKE_BALL")
for _ = 1, 600 do
if not battle.anim then break end
U.wait(1)
end
U.wait(40)
if battle.battle.outcome ~= "caught" and not battle.battle.over then
escaped = true
break
end
if battle.battle.over then break end
end
print(("[v] missball: tries=%d escaped=%s picHidden.enemy=%s outcome=%s")
:format(tries, tostring(escaped), tostring(battle.picHidden.enemy),
tostring(battle.battle.outcome)))
for i = 0, 3 do
U.shot(game, ("%s/missball-%02d.png"):format(OUT, i))
tap(game, "a", 8)
end
end
local name = os.getenv("POKEPORT_VPROBE") or "switch1"
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
print("[v] ---- " .. name)
assert(V[name], "no such probe: " .. name)(game)
print("[v] done, shots in " .. OUT)
love.event.quit()
end
+53 -4
View File
@@ -15,7 +15,8 @@ local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame()
local function makeGame(kind)
kind = kind or "wild"
local save = SaveData.newGame()
save.meta.playthroughId = "battle-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
@@ -29,12 +30,24 @@ local function makeGame()
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
function overworld:captureSave(progress)
progress.player.map = self.map.id
progress.player.x = self.player.cellX
progress.player.y = self.player.cellY
progress.player.facing = self.player.facing
end
local game = { data = Data, save = save, stack = stack, overworld = overworld }
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_B", 12)
local battle = kind == "trainer"
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
or BattleState.newWild(game, "FIXMON_B", 12)
battle.phase = "menu"
battle.queue = {}
battle.checkpointOrigin = { kind = "wild_encounter" }
battle.checkpointOrigin = kind == "trainer"
and { kind = "trainer_encounter", map = save.player.map,
npcId = "TRAINER_1", trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
event = "EVENT_BEAT_TRAINER_1" }
or { kind = "wild_encounter" }
battle.onFinish = function() end
stack.states[2] = battle
return game, overworld, battle
@@ -45,6 +58,42 @@ T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "battle",
}, "settled standard wild battle is a checkpoint boundary")
local function settleRealBattle(kind)
local liveGame, _, liveBattle = makeGame(kind)
liveBattle.phase, liveBattle.queue = nil, {}
liveGame.input = {
wasPressed = function(_, button) return button == "a" end,
isDown = function(_, button) return button == "a" end,
}
liveBattle:enter()
local frames = 0
while liveBattle.phase ~= "menu" and frames < 10000 do
frames = frames + 1
liveBattle:update(1 / 60)
end
T.eq(liveBattle.phase, "menu", "the real battle intro reaches its command menu")
return liveGame
end
local realGame = settleRealBattle("wild")
T.same(Checkpoint.inspect(realGame), {
canCapture = true, canRestore = true, kind = "battle",
}, "the completed real battle intro is a checkpoint boundary")
local oldGetRandomState, oldSetRandomState =
love.math.getRandomState, love.math.setRandomState
love.math.getRandomState = function() return "real-boundary-rng" end
love.math.setRandomState = function() end
local realSnapshot, realCaptureCode = Checkpoint.capture(realGame)
T.check(type(realSnapshot) == "table" and realSnapshot.kind == "battle",
"the first real command decision captures for deferred tools: "
.. tostring(realCaptureCode))
love.math.getRandomState, love.math.setRandomState =
oldGetRandomState, oldSetRandomState
local realTrainerGame = settleRealBattle("trainer")
T.same(Checkpoint.inspect(realTrainerGame), {
canCapture = true, canRestore = true, kind = "battle",
}, "the completed real trainer intro is a checkpoint boundary")
local function refused(mutator, code, label)
local game2, ow2, battle2 = makeGame()
mutator(game2, ow2, battle2)
@@ -64,7 +113,7 @@ refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
refused(function(_, _, b) b.player.mustRecharge = true end,
"battle_phase_busy", "automatic locked action is rejected")
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
"script_busy", "suspended script beneath battle is rejected")
"script_busy", "unknown suspended script beneath battle is rejected")
refused(function(_, _, b) b.checkpointOrigin = nil end,
"battle_origin_unsupported", "unknown completion closure is rejected")
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
+41
View File
@@ -0,0 +1,41 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local DateTime = require("src.core.DateTime")
local SaveData = require("src.core.SaveData")
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
local defaults = SaveData.defaultOptions()
T.eq(defaults.dateFormat, "device", "date format defaults to device locale")
T.eq(defaults.timeFormat, "device", "time format defaults to device locale")
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
T.eq(DateTime.date(game, stamp), os.date("%d-%m-%Y", stamp),
"explicit DMY uses day-month-year")
T.eq(DateTime.time(game, stamp), os.date("%H:%M", stamp),
"explicit 24-hour time omits seconds")
T.eq(DateTime.dateTime(game, stamp),
os.date("%d-%m-%Y %H:%M", stamp),
"combined formatter composes exact date and time preferences")
game.save.options.dateFormat = "mdy"
T.eq(DateTime.date(game, stamp), os.date("%m-%d-%Y", stamp),
"explicit MDY is available")
game.save.options.dateFormat = "ymd"
T.eq(DateTime.date(game, stamp), os.date("%Y-%m-%d", stamp),
"explicit YMD is available")
game.save.options.timeFormat = "12h"
T.eq(DateTime.time(game, stamp), os.date("%I:%M %p", stamp),
"explicit 12-hour time is available")
local fallback = DateTime.formatWithLocale(stamp, "device", "device", "C")
T.eq(fallback.date, os.date("%d-%m-%Y", stamp),
"missing device locale falls back to requested DMY")
T.eq(fallback.time, os.date("%H:%M", stamp),
"missing device locale falls back to requested 24-hour time")
local invalid = DateTime.date({}, -1)
T.eq(invalid, "----", "invalid timestamps fail closed")
T.finish("date_time")
+992
View File
@@ -0,0 +1,992 @@
-- Parity gate for the Gen 1 / Gen 2 mod API boundary.
--
-- The rule this file exists to hold: hook names, event names and registry
-- names are SHARED across generations, and the only things that differ are
-- where a registry's content lands and whether the mod runs at all. A mod
-- opts into Gen 2 with `gen2compat` in its manifest and is left out of a Gold
-- boot entirely without it, because a mod that half-applies reads as broken.
--
-- Runs ROM-free: the generation is injected through the loader rather than by
-- booting Gold (T.sdk.loadMods opts.generation).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local GameVersion = require("src.core.GameVersion")
local Manifest = require("src.mods.Manifest")
local Schemas = require("src.mods.Schemas")
local StateStack = require("src.core.StateStack")
-- ------- 1. the version table knows its generation
T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
local function manifest(extra)
local raw = { id = "fix", name = "Fixture", version = "1.0.0",
entry = "main.lua", api = 2 }
for key, value in pairs(extra or {}) do raw[key] = value end
return Manifest.validate(raw, "mods/fix")
end
T.eq(manifest().gen2compat, false,
"a manifest that says nothing is Gen 1 only")
T.eq(manifest({ gen2compat = true }).gen2compat, true,
"gen2compat = true is carried through")
T.eq(manifest({ gen2compat = false }).gen2compat, false,
"gen2compat = false is carried through")
T.check(not pcall(manifest, { gen2compat = "yes" }),
"a non-boolean gen2compat is rejected")
-- ------- 3. registry target routing
--
-- One routing table per generation (Schemas.routing): Gen 2 keeps the shared
-- target for everything it can serve and reports the rest instead of merging
-- into a table nothing reads, and Gen 1 does the same for the registries that
-- only exist because Gold does. Gen 1 used to consult nothing at all; the
-- catalog now holds content BOTH ways round, so the claim is the symmetrical
-- one -- a registry is gated in a generation exactly when it has no home
-- there, whichever generation that is.
for name, spec in pairs(Schemas.REGISTRIES) do
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
"Gen 1 keeps the catalog target: " .. name)
if Schemas.GEN1[name] == nil then
T.eq(Schemas.gatedFor(name, 1), false,
"a registry Gen 1 routing says nothing about is not gated there: " .. name)
else
-- the mirror of the gated rows below: a Gen 2-only system, so there is no
-- Gen 1 target to keep and a Red mod's write is dropped and reported
-- rather than merged into a namespace no Gen 1 boot reads. The per-
-- registry cases are in tests/engine/gen2_content_registries.lua.
T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name)
T.eq(spec.target, nil,
"a Gen 1-gated registry carries no Gen 1 target: " .. name)
T.check(Schemas.targetFor(name, spec, 2) ~= nil,
"and has a Gen 2 home, or the name is dead in both: " .. name)
end
end
-- the registries Gold genuinely reads off game.data keep their name AND their
-- path, which is what lets one mod source target both generations.
-- `commands` is here rather than in the routed set below because the Gen 2 VM
-- resolves a mod verb out of the SAME merged data.commands table Gen 1's
-- runner does (src/script/gen2/Vm.lua:runModCommand, reached from the
-- Opcodes.MOD_COMMAND row a cart can never write).
for _, name in ipairs({ "pokemon", "moves", "items", "type_chart", "screens",
"strings", "font", "audio", "music", "sfx", "cries",
"map_songs", "commands",
-- `tokens` because TextBox.substitute reads
-- game.data.tokens on every box in both games, and
-- `growth_rates` because src/mods/Builtins.lua's Gen 2
-- registrant seeds Gold's curves as the same
-- { expForLevel } record Gen 1 uses, so one mod record
-- serves both (src/battle/gen2/Mon.lua:growthFor is the
-- single accessor all six Gen 2 readers go through)
"tokens", "growth_rates",
-- `battle_sprite_scales` because
-- src/ui/gen2/BattleState.lua:imageScale walks
-- data.battle_sprite_scales for a record whose .path
-- matches the pic being drawn, skipping `_owners`,
-- exactly as Gen 1's BattleState.imageBattleScale
-- does, and picScale falls through to the species'
-- battleScaleFront / battleScaleBack after it. Only
-- the default differs (Red draws 32x32 back pics at
-- 2x, Gold's 48x48 ones fill their box at 1x) and the
-- default is not a registry record either side.
--
-- `render_pipelines` because src/core/Game2.lua:load
-- calls Pipelines.install(self.data) AFTER mods:load,
-- so src/render/Pipelines.lua walks the MERGED table,
-- and Game2:draw composites the whole-frame half
-- through Pipelines.wantsPresent / Pipelines.present.
-- Gold does not composite `drawWorld` yet -- its
-- overworld draws straight to the window -- and
-- Game2:load retires a restored drawWorld-only level
-- rather than leaving it on and drawing nothing, so
-- that half is inert rather than broken. Routing it
-- is still right: the registry has a live reader, and
-- a gated registry would drop the `present` half too.
"battle_sprite_scales", "render_pipelines" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), spec.target,
"available under Gen 2 at its Gen 1 path: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
end
-- The tables Gold namespaces: same registry NAME, a Gen 2 Data path
-- underneath it. src/core/Game2.lua:load reads each of these into game.data
-- before mods:load runs, and the consumer holds it by reference --
-- src/world/gen2/World.lua:dataTable for the overworld four, the menus for
-- palettes/icons/battle_anims/constants -- so the merge lands in the table the
-- game walks. The battle-rule six have no table on disk at all: they come
-- into existence AS the merge (src/mods/Builtins.lua seeds Gold's own records
-- there), and each consumer reads them through a per-id lookup that falls back
-- to the module's records, so a mod-free boot behaves identically.
-- The pairing is asserted both ways round: routed is NOT the Gen 1 target
-- (that would mean merging into a table no Gold boot reads) and NOT nil (that
-- would mean the write is being dropped).
for name, path in pairs({ maps = "gen2Maps", tilesets = "gen2Tilesets",
sprites = "gen2Sprites", text = "gen2Text",
encounters = "gen2Encounters",
trainers = "gen2Trainers",
palettes = "gen2Palettes", icons = "gen2Icons",
battle_anims = "gen2BattleAnims",
constants = "gen2Constants",
statuses = "gen2Statuses",
move_effects = "gen2MoveEffects",
item_effects = "gen2ItemEffects",
balls = "gen2Balls",
ai_classes = "gen2AiClasses",
evolution_methods = "gen2EvolutionMethods" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), path,
"available under Gen 2 at its Gen 2 path: " .. name)
T.check(Schemas.targetFor(name, spec, 2) ~= spec.target,
"a routed registry does not keep the Gen 1 path: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
"and Gen 1 is untouched by the routing: " .. name)
end
-- The mirror set: six registries that exist because GOLD does. They carry no
-- Gen 1 target at all, so the routed path is the only path they ever have, and
-- Schemas.GEN1 gates them on Red the way Schemas.GEN2 gates `map_scripts` on Gold.
-- Each is held to a live consumer, which is the claim that matters: a routed
-- registry nothing reads is the silent no-op the whole routing table exists to
-- prevent. The per-consumer cases are in
-- tests/engine/gen2_content_registries.lua; here the pairing itself is pinned.
-- held_items src/core/gen2/ItemEffects.lua:heldItemFor / applyHeldItems,
-- written back onto data.items for Battle:itemDef
-- phone_contacts src/core/gen2/Phone.lua:useRegistry
-- decorations src/core/gen2/Decorations.lua:attributes
-- apricorns src/core/gen2/Apricorns.lua:useRegistry
-- landmarks src/core/gen2/Nests.lua:landmarkId / landmark
-- radio_channels src/ui/gen2/MapRadio.lua:channelRecord
for name, path in pairs({ held_items = "gen2HeldItems",
phone_contacts = "gen2PhoneContacts",
decorations = "gen2Decorations",
apricorns = "gen2Apricorns",
landmarks = "gen2Landmarks.landmarks",
radio_channels = "gen2RadioChannels" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), path,
"a Gen 2-only registry is available under Gen 2: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
T.eq(spec.target, nil,
"and carries no Gen 1 target to fall back on: " .. name)
T.eq(Schemas.gatedFor(name, 1), true,
"so a Red boot reports the write rather than merging it: " .. name)
T.eq(Schemas.targetFor(name, spec, 1), nil,
"and has no Gen 1 path at all: " .. name)
end
-- and the ones Gold has no home for are gated, not silently retargeted.
-- One cause is left behind these: Gold reimplements the system WITHOUT
-- reading a registry, so there is no table a merge could land in that anything
-- would read. Closing one is a consumer change in the Gen 2 module first and
-- a routing row second, which is exactly how growth_rates closed --
-- src/battle/gen2/Mon.lua grew growthFor / registerInto and takes an
-- expForLevel record ahead of the coefficient row, so the registry now routes
-- to the SHARED Gen 1 path and one mod record serves both games.
--
-- `tokens` was on this list by mistake rather than by cause: TextBox.new runs
-- TextBox.substitute on every box in both generations and substitute reads
-- game.data.tokens, so the shared target was live on Gold the whole time.
-- `battle_sprite_scales` and `render_pipelines` came off it the way
-- growth_rates did, consumer first: src/ui/gen2/BattleState.lua:imageScale now
-- reads data.battle_sprite_scales, and src/core/Game2.lua:load installs
-- src/render/Pipelines.lua on Gold's merged dataset after mods:load so
-- Game2:draw composites a `present` pipeline. Both are asserted in the shared
-- set above.
--
-- `transitions` stays because Gold's own intro
-- (src/ui/gen2/BattleTransition.lua) keys STYLES as a boolean SET of the four
-- cart wipes rather than the { frames, draw, sound, flash } record this
-- registry carries, and has no styleDef lookup a mod id could reach.
--
-- map_scripts is the one genuine script-side gap left: src/script/gen2/Vm.lua
-- runs the cart's bytecode out of data.gen2Scripts keyed by ROM pointer, and a
-- Lua row list merged into that pool is not something the VM can run.
for _, name in ipairs({ "map_scripts", "rulesets", "transitions",
"field", "text_pointers", "link_fields" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), nil,
"gated registry has no Gen 2 target: " .. name)
T.eq(Schemas.gatedFor(name, 2), true, "gated under Gen 2: " .. name)
end
-- A routed registry is only routed if the records already sitting at that
-- path pass the shared schema, so this pins the two optional warp fields Gen 2
-- carries (the ROM map-group pair) that a strict record would otherwise
-- reject on every one of Gold's 368 maps.
do
local spec = Schemas.REGISTRIES.maps
local gen2Map = {
id = "MOD_TOWN", tileset = "TILESET_JOHTO", width = 2, height = 2,
blocks = { 1, 2, 3, 4 },
warps = { { x = 6, y = 3, destMap = "ELMS_LAB", destWarp = 1,
destGroup = 24, destMapNum = 5 } },
}
T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen2Map, "register"),
"a Gen 2 warp row validates against the shared maps schema")
local gen1Map = {
id = "MOD_TOWN", tileset = "OVERWORLD", width = 2, height = 2,
blocks = { 1, 2, 3, 4 },
warps = { { x = 1, y = 1, destMap = "PALLET_TOWN", destWarp = 1 } },
}
T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen1Map, "register"),
"and the Gen 1 warp row still does, the added fields being optional")
end
-- ------- 3b. the per-generation RECORD shape
--
-- Routing says where a registration lands; this says what a record there looks
-- like. A registry whose Gen 2 records differ carries gen2Fields / gen2Keys /
-- gen2Write beside the Gen 1 slots and Schemas.shapeFor folds them onto the
-- canonical names, which is what let the six shaped registries above be routed
-- at all: without it a Gold species would be judged against Red's `special`.
do
local spec = Schemas.REGISTRIES.pokemon
T.eq(Schemas.shapeFor("pokemon", spec, 1), spec,
"Gen 1 gets the catalog spec itself, not a copy")
local gen2 = Schemas.shapeFor("pokemon", spec, 2)
T.check(gen2 ~= spec, "Gen 2 gets a derived spec")
T.eq(Schemas.shapeFor("pokemon", gen2, 2), gen2,
"resolving a derived spec again is a no-op")
T.check(gen2.fields.levelMoves ~= nil and gen2.fields.level1Moves == nil,
"the Gen 2 species shape is folded onto `fields`")
T.eq(gen2.gen2Fields, nil, "and the gen2* keys are gone from the derived spec")
T.eq(gen2.target, "pokemon",
"a reshaped registry that is not rerouted keeps its path")
-- the split special stats, which is the difference that makes register
-- usable on Gold at all
local gold = {
id = "MODMON", name = "MODMON", dex = 252,
types = { "GRASS" },
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
specialAttack = 65, specialDefense = 65 },
catchRate = 45, baseExp = 64, growthRate = "MEDIUM_SLOW",
levelMoves = { { level = 1, move = "FIX_TACKLE" } },
evolutions = {},
spriteFront = "a.png", spriteBack = "b.png", picSize = 5,
}
T.check(Schemas.check(spec, "pokemon", "MODMON", gold, "register", 2),
"a Gen 2 species record registers under Gen 2")
T.check(not Schemas.check(spec, "pokemon", "MODMON", gold, "register", 1),
"and the same record is not a Gen 1 species")
local _, err = Schemas.check(spec, "pokemon", "FIXMON_A",
{ baseStats = { special = 80 } }, "patch", 2)
T.check(err ~= nil and err:match("special"),
"a Gen 1 baseStats.special is rejected under Gen 2: " .. tostring(err))
end
do
-- trainers routes one level further in, into .classes, and battle_anims
-- CLEARS the Gen 1 write (there the ids are the subtables the Gen 1 write
-- would have routed into)
local trainers = Schemas.shapeFor("trainers", Schemas.REGISTRIES.trainers, 2)
T.eq(trainers.target, "gen2Trainers", "the derived spec carries the routed path")
T.check(trainers.write ~= nil and trainers.baseAt ~= nil
and trainers.baseIds ~= nil,
"trainers reaches into .classes through write/baseAt/baseIds")
local anims = Schemas.shapeFor("battle_anims", Schemas.REGISTRIES.battle_anims, 2)
T.eq(anims.write, nil, "gen2Write = false clears the Gen 1 write")
T.eq(anims.baseAt, nil, "and the Gen 1 baseAt with it")
T.check(anims.keys ~= nil and anims.value == nil,
"a Gen 2 shape described by keys clears the Gen 1 value slot")
end
-- a Gen 2 shape on a registry with no Gen 2 home would be dead code: nothing
-- ever validates against it, because the write is dropped before it is checked
for name, spec in pairs(Schemas.REGISTRIES) do
if Schemas.hasGen2Shape(spec) then
T.eq(Schemas.gatedFor(name, 2), false,
"a registry with a Gen 2 shape is not gated: " .. name)
end
end
-- every routing entry names a real registry, so a rename cannot leave a
-- stale row behind that silently stops gating anything
for name in pairs(Schemas.GEN2) do
T.check(Schemas.REGISTRIES[name] ~= nil,
"Schemas.GEN2 names a real registry: " .. name)
end
-- a routed path must not be some other registry's path: two registries
-- folding into one table would let the second one's ids overwrite the first's
do
local claimed = {}
for name, spec in pairs(Schemas.REGISTRIES) do
local path = Schemas.targetFor(name, spec, 2)
if path then
T.check(claimed[path] == nil or claimed[path] == name,
("two registries share one Gen 2 path (%s): %s and %s")
:format(path, tostring(claimed[path]), name))
claimed[path] = name
end
end
end
-- ------- 4. the shared names, raised from Gold's own call sites
--
-- The rule: when a Gen 2 call site lands for something Gen 1 already names,
-- it reuses the EXACT name, so one mod's subscription serves both games. The
-- catalog reads the names back out of the source (tests/modkit/catalog.lua
-- scans for Runtime.emit / Runtime.call), so each name below is held to
-- having BOTH a site inside a Gen 2 module and a site outside one. A
-- "gen2.world.stepped" would satisfy the first half and fail the second,
-- which is exactly the drift this gate exists to catch; so would quietly
-- deleting Gold's site while docs/mod-api-gen2-compat.md still promises it.
--
-- Payload parity cannot be checked here (the shapes come from a live Gold
-- boot, and this file is ROM-free); tests/engine/gate_hooks.lua and
-- gate_events.lua carry the per-payload cases, and the Gen 2 sites were
-- proved against the gold_* drivers.
local Catalog = T.catalog
-- Gold's modules live under a gen2/ directory, except the two that own the
-- boot and the extractor and carry the generation in their name
local function isGen2Site(path)
return path:match("gen2") ~= nil or path:match("Gen2") ~= nil
or path:match("Game2") ~= nil
end
local GEN2_EVENTS = {
-- overworld
"map.entered", "map.exited", "map.reloaded", "player.warped",
"world.stepped", "world.interacted", "world.npc_spawned",
"world.trainer_engaged", "world.blacked_out", "world.block_replaced",
"world.boulder_moved", "world.tod_changed", "world.object_toggled",
"flag.changed",
-- battle
"battle.started", "battle.ended", "battle.turn_started", "battle.turn_ended",
"battle.move_used", "battle.damage_dealt", "battle.fainted",
"battle.status_inflicted", "battle.battler_switched", "battle.ball_thrown",
"battle.exp_gained", "pokemon.level_up", "pokemon.move_learned",
-- the catch and the evolution themselves: pushCaught emits after the mon is
-- in the party or the box, Evolution.apply after the species swap, both
-- matching the Gen 1 payload keys
"pokemon.caught", "pokemon.evolved",
-- boot, save and the script VM
"game.ready", "save.created", "save.loaded", "save.loading", "save.writing",
"script.started", "script.ended",
-- The new-game speech. Gold's is a different scene (Elm, not Oak, and its
-- own src/ui/gen2/OakSpeech.lua), but it is the SAME moment -- the intro
-- asking the player for the answers a save is built from -- so it keeps Gen
-- 1's four names and payload keys rather than inventing "intro.elm_speech".
"intro.oak_speech.started", "intro.oak_speech.step",
"intro.oak_speech.answered", "intro.oak_speech.finished",
}
local GEN2_HOOKS = {
-- overworld
"warp.destination", "movement.collision", "movement.speed",
"encounter.roll", "encounter.species", "encounter.fishing",
"world.tod", "map.palette", "fieldmove.eligibility",
-- menus and the battle intro
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
"ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu",
"transition.style",
-- battle
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
"battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain",
"catch.rate", "trainer.party",
-- one wrap cancels or forces an evolution in either game: Gold passes `data`
-- where Gen 1 passes `game`, and positions 2-4 (mon, row, trigger) match
"evolution.check",
-- save and the script VM
"save.write", "save.new_game", "script.command",
-- the intro's step list, wrapped before the first card draws. Same hook,
-- same (steps, speech) arguments and same "return the list" contract as
-- src/ui/OakSpeech.lua's, so one wrapper reorders either game's speech.
"intro.oak_speech.build",
-- Battle seams Gold raises from src/battle/gen2/Battle.lua and
-- src/ui/gen2/BattleState.lua. battle.low_health_alarm carries `data` on
-- Gold where Gen 1's vanilla link reads ctx.battle.data: Gold's battle
-- screen has no .data field, so the key is ADDED beside the Gen 1 ones
-- rather than the payload being reshaped (docs/mod-api-gen2-compat.md warns
-- that a Gen 1 mod reaching through ctx.battle.data instead of calling
-- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
-- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
-- concept of.
"pokemon.sprite",
-- The player's own trainer pic, same story: the Runtime.call is the shared
-- src/pokemon/Sprites.lua and Gold's battle back pic, Hall of Fame and intro
-- resolve their own path into Sprites.playerPic with the Gen 1 ctx keys.
"player.sprite",
-- The frame itself, from src/core/Game2.lua, in the same places
-- src/core/Game.lua raises them: the logic tick before the pad is read, a
-- pointer with the touch overlay given first refusal, the palette zone list
-- handed to the present pass, the letterbox and the HUD rect.
"input.step", "input.pointer",
"render.zones", "render.compose", "render.letterbox", "render.hud",
}
local function assertShared(name, sites, kind)
local gen2, gen1 = 0, 0
for _, path in ipairs(sites) do
if isGen2Site(path) then gen2 = gen2 + 1 else gen1 = gen1 + 1 end
end
T.check(gen2 > 0, ("Gold raises the %s: %s"):format(kind, name))
T.check(gen1 > 0,
("the %s %s is shared, not a Gen 2 invention (no Gen 1 site)")
:format(kind, name))
end
for _, name in ipairs(GEN2_EVENTS) do
assertShared(name, Catalog.eventSites(name), "event")
end
for _, name in ipairs(GEN2_HOOKS) do
assertShared(name, Catalog.hookSites(name), "hook")
end
-- and the lists are COMPLETE, not a sample. Without this half the gate only
-- catches a seam being taken away; a Gen 2 site landing for a Gen 1 name and
-- never reaching docs/mod-api-gen2-compat.md is the other drift, and it is the
-- more likely one -- the doc is where an author looks to decide whether a
-- subscription serves both games, so an unlisted shared seam reads as absent.
local function assertListed(names, catalogNames, sites, kind)
local listed = {}
for _, name in ipairs(names) do listed[name] = true end
for _, name in ipairs(catalogNames) do
if not Catalog.isModEvent(name) then
local gen2, gen1 = false, false
for _, path in ipairs(sites(name)) do
if isGen2Site(path) then gen2 = true else gen1 = true end
end
if gen2 and gen1 then
T.check(listed[name],
("%s %s has a site in both generations but is not in this gate's "
.. "list; add it here and to docs/mod-api-gen2-compat.md")
:format(kind, name))
end
end
end
end
assertListed(GEN2_EVENTS, Catalog.events(), Catalog.eventSites, "event")
assertListed(GEN2_HOOKS, Catalog.hooks(), Catalog.hookSites, "hook")
-- and nothing anywhere invents a generation-prefixed name. New-in-Gen-2
-- systems (held_item.trigger, egg.hatched) get plain names of their own;
-- "gen2." would be a namespace no Gen 1 mod could ever match.
for _, name in ipairs(Catalog.events()) do
T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed event: " .. name)
end
for _, name in ipairs(Catalog.hooks()) do
T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed hook: " .. name)
end
-- ------- 4b. the seams Gen 2 invents
--
-- The other half of the shared-name rule. Section 4 holds a name Gen 1
-- already has to keeping it; these are the systems Red does not have at all
-- (friendship, breeding, the Pokegear, the radio, Pokerus, the roamers, Kurt,
-- the Bug Contest, the Unown puzzle, mail, held items, shininess and gender),
-- so a NEW name is justified -- and the discipline is the same one from the
-- other side: a plain name, never a "gen2." namespace no Gen 1 mod could
-- match, so that when Red ever grows the system the name is already right.
--
-- Three things are asserted per seam, and each one has failed at some point in
-- a review of this programme:
--
-- 1. the site exists at all. docs/mod-api-gen2-compat.md promises these by
-- name, so a deleted emit is doc drift the moment it happens.
-- 2. every site is inside a Gen 2 module. If a Gen 1 site ever appears the
-- seam is no longer Gen 2-only and belongs in the shared lists above,
-- where BOTH halves are checked -- this is the tripwire for that move.
-- 3. the site is guarded by Runtime.wants / wantsHook for its own name, so a
-- mod-free boot allocates no payload table. Several of these sit in the
-- step loop (happiness.changed, roamer.moved) or in the damage path
-- (held_item.trigger, eight triggers a turn), where an unguarded emit is
-- a per-frame cost every player pays for a feature nobody enabled.
--
-- Payload keys are not checkable here (this file is ROM-free);
-- tests/engine/gen2_new_seams.lua drives each one through a live bus and
-- asserts the payload the call site documents.
local GEN2_ONLY_EVENTS = {
"happiness.changed", "breeding.egg_created", "egg.hatched",
"phone.call_received", "clock.day_changed", "pokerus.infected",
"roamer.moved", "roamer.encountered", "apricorn.converted",
"bug_contest.scored", "unown.unlocked", "radio.channel",
"mail.written", "mail.read",
-- The GS boot cinema, card by card. Red boots straight into its title
-- screen, so there is no Gen 1 moment for these to share a name with; they
-- are plain names rather than "gen2." ones so that the day Red grows a
-- cinema the name is already right. Each fires as its card comes UP, with
-- movie_ended the one card END worth a name of its own (it is where the
-- attract loop restarts).
"intro.boot.copyright", "intro.boot.gamefreak", "intro.boot.movie",
"intro.boot.movie_ended", "intro.boot.title",
}
local GEN2_ONLY_HOOKS = {
"held_item.trigger", "breeding.compatibility", "phone.contact_list",
"shiny.roll", "gender.roll",
}
local sourceCache = {}
local function sourceOf(path)
if sourceCache[path] == nil then
local handle = io.open(path, "r")
sourceCache[path] = handle and handle:read("*a") or false
if handle then handle:close() end
end
return sourceCache[path] or nil
end
local function assertGen2Only(name, sites, kind, guard)
T.check(#sites > 0, ("Gold raises the Gen 2-only %s: %s"):format(kind, name))
local guarded = false
for _, path in ipairs(sites) do
T.check(isGen2Site(path),
("a Gen 2-only %s is raised from a Gen 2 module (%s is not one): %s")
:format(kind, path, name))
local body = sourceOf(path)
if body and body:find(('%s("%s")'):format(guard, name), 1, true) then
guarded = true
end
end
T.check(guarded,
("the %s %s is guarded by %s, so a mod-free boot pays nothing")
:format(kind, name, guard))
end
for _, name in ipairs(GEN2_ONLY_EVENTS) do
assertGen2Only(name, Catalog.eventSites(name), "event", "Runtime.wants")
end
for _, name in ipairs(GEN2_ONLY_HOOKS) do
assertGen2Only(name, Catalog.hookSites(name), "hook", "Runtime.wantsHook")
end
-- complete both ways, like the shared lists: a seam raised ONLY from Gen 2
-- modules is by definition a Gen 2-only one, so if it is not listed above it
-- has skipped the guard check, the doc's payload table and gen2_new_seams.lua
-- all at once.
local function assertGen2OnlyListed(names, catalogNames, sites, kind)
local listed = {}
for _, name in ipairs(names) do listed[name] = true end
for _, name in ipairs(catalogNames) do
if not Catalog.isModEvent(name) then
local anyGen1 = false
for _, path in ipairs(sites(name)) do
if not isGen2Site(path) then anyGen1 = true end
end
if not anyGen1 then
T.check(listed[name],
("%s %s is raised from Gen 2 modules alone but is not listed as a "
.. "Gen 2-only seam; add it here and to "
.. "docs/mod-api-gen2-compat.md"):format(kind, name))
end
end
end
end
assertGen2OnlyListed(GEN2_ONLY_EVENTS, Catalog.events(), Catalog.eventSites,
"event")
assertGen2OnlyListed(GEN2_ONLY_HOOKS, Catalog.hooks(), Catalog.hookSites,
"hook")
-- ------- 5. the gate, through a real load
local GEN1_ONLY = {
["mods/fix_gen1_only/manifest.json"] = [[{
"id": "fix_gen1_only",
"name": "Fixture Gen 1 Only",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_gen1_only/main.lua"] = [[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", { catchRate = 111 })
]],
}
local GEN2_READY = {
["mods/fix_gen2_ready/manifest.json"] = [[{
"id": "fix_gen2_ready",
"name": "Fixture Gen 2 Ready",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true
}]],
["mods/fix_gen2_ready/main.lua"] = [[
local mod = ...
-- one registry with a Gen 2 home, one without: the first applies in both
-- generations, the second applies in Gen 1 and reports in Gen 2.
-- `transitions` is the gated one because Gold draws its own battle intro
-- (src/ui/gen2/BattleTransition.lua) and never composes through the Gen 1
-- Renderer, so nothing on a Gold boot would ever read the merged record.
mod.content.pokemon:patch("FIXMON_A", { catchRate = 123 })
mod.content.transitions:register("FIXTURE_WIPE", { frames = 30 })
]],
}
local function files(...)
local out = {}
for _, set in ipairs({ ... }) do
for path, body in pairs(set) do out[path] = body end
end
return out
end
local function statusOf(run, id)
for _, entry in ipairs(run.loader:status().available) do
if entry.id == id then return entry end
end
return nil
end
-- Gen 1: both mods run, both patches land
do
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)),
generation = 1,
})
T.eq(statusOf(run, "fix_gen1_only").state, "loaded",
"Gen 1: a mod with no gen2compat loads")
T.eq(statusOf(run, "fix_gen2_ready").state, "loaded",
"Gen 1: a gen2compat mod loads too")
T.eq(run.data.pokemon.FIXMON_A.catchRate, 123,
"Gen 1: the later mod's patch merged")
T.check(run.data.transitions ~= nil
and run.data.transitions.FIXTURE_WIPE ~= nil,
"Gen 1: transitions merged")
run.release()
end
-- Gen 2: the undeclared mod is skipped whole, the declared one runs
do
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)),
generation = 2,
})
local skipped = statusOf(run, "fix_gen1_only")
T.eq(skipped.state, "wrong_generation",
"Gen 2: a mod with no gen2compat is not loaded")
T.check(skipped.note ~= nil and skipped.note:match("gen2compat"),
"Gen 2: the skip says why")
T.eq(skipped.error, nil,
"Gen 2: a skip is not reported as a failure")
T.eq(skipped.enabled, true,
"Gen 2: the player's enable flag is untouched by the skip")
T.eq(statusOf(run, "fix_gen2_ready").state, "loaded",
"Gen 2: the declared mod loads")
-- the skipped mod's registration must leave no trace: 111 would mean it ran
T.eq(run.data.pokemon.FIXMON_A.catchRate, 123,
"Gen 2: only the declared mod's patch merged")
-- a gated registry takes the write, drops it, and says so
T.check(run.data.transitions == nil
or run.data.transitions.FIXTURE_WIPE == nil,
"Gen 2: a gated registry merges nothing")
local told = false
for _, message in ipairs(run.errors) do
if message:match("transitions") and message:match("Gen 2") then told = true end
end
T.check(told, "Gen 2: the dropped registration is reported, not silent")
run.release()
end
-- The drop is worded from the loader's own generation, because the gating runs
-- both ways: a Red boot rejecting a write to a Gen 2-only registry must not
-- claim the registry has "no Gen 2 target". The registry name and the drop
-- were always right; the sentence was one-directional.
do
local MIRROR = {
["mods/fix_gen1_drop/manifest.json"] = [[{
"id": "fix_gen1_drop",
"name": "Fixture Gen 1 Drop",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_gen1_drop/main.lua"] = [[
local mod = ...
mod.content.decorations:patch("deco:2", { name = "COZY" })
]],
}
local run = T.sdk.loadMods({ "mods/fix_gen1_drop" },
{ fs = T.sdk.memfs(MIRROR), generation = 1 })
local told
for _, message in ipairs(run.errors) do
if message:match("decorations") then told = message end
end
T.check(told ~= nil,
"Gen 1: a write to a Gen 2-only registry is reported")
T.check(told and told:match("Gen 1"),
"Gen 1: and the report names Gen 1, not Gen 2: " .. tostring(told))
T.eq(run.data.gen2Decorations, nil, "Gen 1: and nothing merged")
run.release()
end
-- ------- 5b. the vanilla records at a routed path are GOLD's
--
-- Six of the routed registries are the battle rules, and there the registry is
-- not just a merge target: src/battle/gen2/Catching.lua:recordFor,
-- Battle.statusRecordFor / moveEffectRecordFor, Ai.layersFor,
-- Evolution.methodFor and src/core/gen2/ItemEffects.lua:recordFor all read the
-- merged table. So WHICH module seeds it is load bearing, and it is not the
-- one that seeds Red: the ids collide. src/mods/Builtins.lua swaps the
-- registrant per generation and this holds it to that -- seeding Red's
-- GREAT_BALL would leave Gold's x1.5 multiplier nil, which reads as a ball
-- that quietly stopped working.
do
local gen1 = T.sdk.loadNone({ generation = 1 })
local gen2 = T.sdk.loadNone({ generation = 2 })
local ball1 = gen1.loader.content.balls:get("GREAT_BALL")
local ball2 = gen2.loader.content.balls:get("GREAT_BALL")
T.check(ball1 ~= nil and ball1.hpFactor ~= nil and ball1.multiplier == nil,
"Gen 1 seeds Red's GREAT_BALL (an HP factor, no multiplier)")
T.check(ball2 ~= nil and ball2.multiplier == 1.5,
"Gen 2 seeds Gold's GREAT_BALL (the x1.5 the cart multiplies by)")
-- statuses are the clearest case of the shared NAME over different ids:
-- Red writes BRN into mon.status where Gold writes "burn"
T.check(gen1.loader.content.statuses:get("BRN") ~= nil,
"Gen 1 seeds Red's status ids")
T.check(gen2.loader.content.statuses:get("burn") ~= nil
and gen2.loader.content.statuses:get("BRN") == nil,
"Gen 2 seeds Gold's status ids and none of Red's")
-- Ai.layersFor walks the merged table for mod-registered scoring passes, so
-- Red's LAYER_1..LAYER_3 landing there would join Gold's ten
T.check(gen1.loader.content.ai_classes:get("LAYER_1") ~= nil,
"Gen 1 seeds Red's move-scoring layers")
T.check(gen2.loader.content.ai_classes:get("LAYER_1") == nil
and gen2.loader.content.ai_classes:get("SMART") ~= nil,
"Gen 2 seeds Gold's scoring passes instead")
-- and the Gen 2 VM's verb table is the mod verbs alone: a Gen 1 row-list
-- verb handed Gold's ctx would find no runner on it
T.check(gen1.loader.content.commands:get("show_text") ~= nil,
"Gen 1 seeds the row-list verbs")
T.eq(gen2.loader.content.commands:get("show_text"), nil,
"Gen 2 seeds none of them")
T.eq(gen2.data.commands, nil,
"and a mod-free Gold boot leaves data.commands absent entirely")
-- the seeded records land at the routed path, not the Gen 1 one
T.check(gen2.data.gen2Statuses ~= nil and gen2.data.gen2Statuses.burn ~= nil,
"the Gen 2 records merge into their Gen 2 path")
T.eq(gen2.data.statuses, nil,
"and nothing is written to the Gen 1 path a Gold boot never reads")
T.check(gen1.data.statuses ~= nil and gen1.data.statuses.BRN ~= nil,
"while Gen 1 is untouched by any of it")
gen1.release()
gen2.release()
end
-- A gated registry is an absent id space, not an empty one. Gold's species
-- carry a growthRate exactly as Red's do, so a patch that keeps one must not
-- be reported as referencing something that does not exist just because the
-- Gen 1 `growth_rates` namespace has no Gen 2 home. A ROUTED registry is the
-- opposite: `evolution_methods` has real ids on Gold now, so the same pass
-- resolves an evolution's method against them and a typo is caught.
local function refsFixture(body)
return {
["mods/fix_refs/manifest.json"] = [[{
"id": "fix_refs",
"name": "Fixture Refs",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true
}]],
["mods/fix_refs/main.lua"] = body,
}
end
-- The ROM-free fixture dataset is Gen 1 shaped, and Gold hangs its experience
-- curves off data.pokemon.growthRates (which src/mods/Builtins.lua's Gen 2
-- registrant seeds the growth_rates registry from). A generation-2 run over
-- unmodified fixtures therefore seeds no curves, and every fixture species'
-- growthRate reads as a dangling reference -- an artifact of the dataset, not
-- of the engine: on a real Gold boot the ids line up exactly (both sides say
-- GROWTH_MEDIUM_SLOW). Added per-run rather than to tests/fixture_data, whose
-- shape is Gen 1's and whose fingerprint is a committed golden.
local function gen2Fixtures()
local data = T.fixtures.fresh()
-- pokegold data/growth_rates.asm's MEDIUM_SLOW row, under the id the fixture
-- species reference
data.pokemon.growthRates = {
MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
linear = 100, constant = 140 },
}
return data
end
local function danglingRefs(run)
local dangling = {}
for _, message in ipairs(run.errors) do
if message:match("unresolved reference") then
dangling[#dangling + 1] = message
end
end
return dangling
end
do
-- the Gen 2 evolution row shape: `into` rather than `species`, and Gold's
-- own EVOLVE_* method ids, which src/core/gen2/Evolution.lua seeds
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
fs = T.sdk.memfs(refsFixture([[
local mod = ...
local base = mod.content.pokemon:get("FIXMON_A")
mod.content.pokemon:patch("FIXMON_A", {
catchRate = 90,
growthRate = base.growthRate,
evolutions = { { method = "EVOLVE_LEVEL", level = 16,
into = "FIXMON_B" } },
})
]])),
data = gen2Fixtures(),
generation = 2,
})
local dangling = danglingRefs(run)
T.eq(#dangling, 0,
"Gen 2: a record whose refs all resolve reports nothing ("
.. table.concat(dangling, "; ") .. ")")
T.eq(run.data.pokemon.FIXMON_A.catchRate, 90, "Gen 2: the patch still landed")
run.release()
end
do
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
fs = T.sdk.memfs(refsFixture([[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", {
evolutions = { { method = "EVOLVE_BY_VIBES", level = 16,
into = "FIXMON_B" } },
})
]])),
data = gen2Fixtures(),
generation = 2,
})
local dangling = danglingRefs(run)
T.eq(#dangling, 1,
"Gen 2: a routed registry HAS an id space, so a bad method is caught")
T.check(dangling[1] and dangling[1]:match("evolution_methods"),
"Gen 2: and the report names the registry it could not resolve against")
run.release()
end
-- the skip is contagious as a SKIP. A mod that DID claim gen2compat but sits
-- on one that did not is left out with the dependency's own wording, not
-- failed with "dependency X failed to load": neither mod has a bug and neither
-- belongs on the boot error list the player is shown.
do
local DEPENDENT = {
["mods/fix_gen2_dependent/manifest.json"] = [[{
"id": "fix_gen2_dependent",
"name": "Fixture Gen 2 Dependent",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true,
"dependencies": ["fix_gen1_only"]
}]],
["mods/fix_gen2_dependent/main.lua"] = [[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", { catchRate = 222 })
]],
}
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_dependent" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, DEPENDENT)),
generation = 2,
})
local dependent = statusOf(run, "fix_gen2_dependent")
T.eq(dependent.state, "wrong_generation",
"Gen 2: a dependent of a gate-skipped mod is skipped, not failed")
T.eq(dependent.error, nil,
"Gen 2: the dependent's skip is not reported as a failure")
T.check(dependent.note ~= nil and dependent.note:match("gen2compat"),
"Gen 2: the dependent's skip names the dependency's reason")
T.eq(#run.errors, 0, "Gen 2: neither mod contributes a boot error")
run.release()
end
-- the player's override: options.modsGen2 forces a mod past the gate, because
-- the manifest flag is the AUTHOR's claim and a mod written before the field
-- existed can never carry one
do
local fs = T.sdk.memfs(files(GEN1_ONLY))
fs.write("options.lua", require("src.core.SaveSerializer").encode({
mods = {}, modsGen2 = { fix_gen1_only = true },
}))
local run = T.sdk.loadMods({ "mods/fix_gen1_only" },
{ fs = fs, generation = 2 })
local forced = statusOf(run, "fix_gen1_only")
T.eq(forced.state, "loaded", "Gen 2: the override loads an unclaimed mod")
T.eq(forced.gen2Forced, true, "Gen 2: the manager sees the override")
T.check(forced.note ~= nil and forced.note:match("not verified"),
"Gen 2: a forced mod still says its author never claimed this game")
T.eq(run.data.pokemon.FIXMON_A.catchRate, 111,
"Gen 2: the forced mod's patch merged")
run.release()
end
-- a skipped mod is skipped before validation, so a Gen 1 mod with a broken
-- manifest does not ALSO shout about its entry file on a Gold boot
do
local BROKEN = {
["mods/fix_broken/manifest.json"] = [[{
"id": "fix_broken",
"name": "Fixture Broken",
"version": "1.0.0",
"entry": "missing.lua",
"api": 2
}]],
}
local run = T.sdk.loadMods({ "mods/fix_broken" },
{ fs = T.sdk.memfs(BROKEN), generation = 2 })
T.eq(statusOf(run, "fix_broken").state, "wrong_generation",
"Gen 2: the generation gate runs before entry-file validation")
T.eq(#run.errors, 0, "Gen 2: a skipped mod contributes no boot errors")
run.release()
end
-- ------- 6. StateStack:clear, which is what Gold's boot cinema hands off
-- through now that it runs the engine stack
do
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local order = {}
local function state(name)
return { isOpaque = true, exit = function() order[#order + 1] = name end }
end
stack:push(state("a"))
stack:push(state("b"))
stack:push(state("c"))
stack:clear()
T.eq(stack:top(), nil, "clear empties the stack")
T.eq(table.concat(order, ","), "c,b,a", "clear unwinds top-first")
end
-- Without this the file printed its FAILs and exited 0, so the runner marked
-- the gate "ok" while it was red -- a gate that cannot fail is not a gate.
T.finish("gate_gen2_mod_api")
+640
View File
@@ -0,0 +1,640 @@
-- Gate for the Gen 1 module facades a gen2compat mod's require resolves to
-- (src/mods/Gen2Compat.lua).
--
-- The rules this file holds, all of them load-bearing for a Gen 1 follower mod
-- running on Gold:
-- * every served name hands back one stable table, and the ones backed by a
-- Gen 2 module ARE that module, so a monkey-patch lands where Gold runs;
-- * the Game facade is a live proxy, not a snapshot, and aliases the two
-- names Gold spells differently (overworld / writeOptions) plus the one
-- data table that was renamed (sprites);
-- * src/world/gen2/Follower.lua keeps a file-local named exactly
-- `shouldSpawn`, shared by update and onMapEntered, because that upvalue
-- NAME is what three separate follower mods rewrite through
-- debug.setupvalue;
-- * World:step ticks the follower and the overworld facade unconditionally,
-- and World:interact dispatches through the facade when one is installed.
--
-- ROM-free: the world here is hand-built, the way tests/gen2_world_test.lua
-- builds one.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local S = require("tests.harness").suite("gen2 mod facade")
local check, eq = S.check, S.eq
local Gen2Compat = require("src.mods.Gen2Compat")
local Follower = require("src.world.gen2.Follower")
local Gen2Map = require("src.world.gen2.Map")
local Gen2Npc = require("src.world.gen2.Npc")
local Player = require("src.world.gen2.Player")
local World = require("src.world.gen2.World")
-- ------- 1. every served name resolves, once, to a table
local SERVED = {
"src.core.Game", "src.world.NPC", "src.world.Collision",
"src.world.FieldDefaults", "src.pokemon.Boxes",
"src.world.OverworldController", "src.world.PikachuFollower",
"src.world.Map", "src.world.WorldAPI", "src.ui.PartyMenu", "src.ui.BoxMenu",
"src.ui.StartMenu", "src.ui.OptionsMenu", "src.battle.BattleState",
"src.script.ScriptRunner",
}
for _, name in ipairs(SERVED) do
check(Gen2Compat.serves(name), "served: " .. name)
local a = Gen2Compat.resolve(name, "fixture")
eq(type(a), "table", "resolves to a table: " .. name)
check(a == Gen2Compat.resolve(name, "fixture"),
"one stable table for the run: " .. name)
end
-- the aliased ones ARE the Gen 2 module, so a mod's patch lands on the table
-- Gold runs rather than on a copy
eq(Gen2Compat.resolve("src.world.PikachuFollower"), Follower,
"PikachuFollower is src/world/gen2/Follower.lua itself")
eq(Gen2Compat.resolve("src.world.Map"), Gen2Map, "Map is the Gen 2 Map")
eq(Gen2Compat.resolve("src.world.NPC"), Gen2Npc,
"NPC is the Gen 2 NPC, so getmetatable(npc) == the module the mod required")
-- Gen 1's BoxMenu is Bill's PC TOP MENU; Gold's counterpart is PcMenu, and
-- the Gen 2 BoxMenu is the withdraw/deposit LIST Gen 1 builds inline. Aimed
-- at the wrong one, a mod appending a row appends it to an object with no
-- row list at all.
eq(Gen2Compat.resolve("src.ui.BoxMenu"), require("src.ui.gen2.PcMenu"),
"BoxMenu is Gold's PC top menu, not its box list")
-- Gold has no BattleState.newWild, and inventing one that took a species and a
-- level would be the silent wrong answer this whole layer exists to avoid.
check(Gen2Compat.resolve("src.battle.BattleState").newWild == nil,
"no invented newWild on the Gen 2 battle screen")
-- ------- 1b. the coverage table, which the modkit checker reads
eq(Gen2Compat.COVERAGE_VERSION, 1, "the coverage contract is versioned")
local names = Gen2Compat.modules()
eq(#names, #SERVED, "every served name is in the coverage listing")
for _, name in ipairs(names) do
local row = Gen2Compat.coverage(name)
check(row ~= nil, "coverage for " .. name)
eq(row.module, name, "coverage names itself: " .. name)
check(row.kind == "facade" or row.kind == "alias",
"coverage kind is facade or alias: " .. name)
for member, status in pairs(row.members) do
check(status == "backed" or status == "warned" or status == "absent",
("%s.%s carries one of the three statuses"):format(name, member))
end
end
eq(Gen2Compat.coverage("src.pokemon.Boxes").kind, "facade",
"Boxes is a facade over the Gen 2 module, not an alias")
eq(Gen2Compat.memberStatus("src.pokemon.Boxes", "deposit"), "backed",
"the deposit override is published as backed")
eq(Gen2Compat.memberStatus("src.battle.BattleState", "makeSafari"), "absent",
"makeSafari is published absent, which is what the wilds mod probes for")
eq(Gen2Compat.memberStatus("src.world.Collision", "load"), "warned",
"Collision.load is present, answers nil and says so")
eq(Gen2Compat.coverage("nope.nope"), nil, "an unserved name has no coverage")
-- a table per call, so a consumer cannot mutate the adapter's own record
local first = Gen2Compat.coverage("src.world.Collision")
first.members.canMove = "absent"
eq(Gen2Compat.memberStatus("src.world.Collision", "canMove"), "backed",
"coverage hands back a fresh table each call")
-- ------- 2. the Game facade proxies a LIVE game
local persisted = 0
local liveGame = {
world = { tag = "the world" },
save = { party = {} },
stack = { tag = "the stack" },
data = { gen2Sprites = { SPRITE_CHRIS = { id = "SPRITE_CHRIS" } },
gen2Maps = { TEST_MAP = { id = "TEST_MAP" } },
gen2Constants = { specialOrder = {} },
pokemon = { PIDGEY = { name = "PIDGEY" } } },
persistOptions = function() persisted = persisted + 1 end,
}
local current = nil
Gen2Compat.bind(function() return current end)
local Game = Gen2Compat.resolve("src.core.Game", "fixture")
eq(Game.save, nil, "captured before a game exists, the facade reads nil")
current = liveGame
eq(Game.save, liveGame.save, "and fills in the moment one is wired")
eq(Game.overworld, liveGame.world, "overworld is the Gen 1 name for .world")
eq(Game.stack, liveGame.stack, "the stack is the real one, so a patch of "
.. "stack.push reaches the engine")
eq(Game.data.pokemon.PIDGEY.name, "PIDGEY", "data forwards")
eq(Game.data.sprites, liveGame.data.gen2Sprites,
"data.sprites is the Gen 1 name for gen2Sprites")
eq(Game.data.field, nil, "data.field has no Gen 2 backing and says so")
Game.writeOptions(Game)
eq(persisted, 1, "writeOptions is persistOptions, called with the live game")
Game._fixtureStamp = true
eq(liveGame._fixtureStamp, true, "a stamp written through the facade lands on "
.. "the live game")
-- the four Gen 1 members Gold has no counterpart for are NAMED, not answered
eq(Game.renderer, nil, "Game.renderer is absent, not the real Renderer: half "
.. "its surface would answer and the other half silently no-op")
eq(Game.load, nil, "Game:load is absent; calling it would re-run Gold's boot")
eq(Game.bootConfig, nil, "Game:bootConfig is absent")
eq(Game.makeTitleState, nil, "Game:makeTitleState is absent")
eq(Game.data.constants, nil, "data.constants is NOT routed to gen2Constants, "
.. "which is the cart's ordered name lists and a different thing entirely")
eq(Game.data.maps, liveGame.data.gen2Maps, "data.maps is the Gen 1 name")
eq(Game.logicSpeed(), 1, "logicSpeed never returns below 1 on Gold")
eq(type(Game.fixedStep), "table", "fixedStep is the shared singleton")
-- ------- 3. NPC: the Gen 1 constructor shape, a Gen 2 entity out
local NPCFacade = Gen2Compat.resolve("src.world.NPC", "fixture")
local sheet = "assets/fixture/follower.png"
local sprites = { SPRITE_PIKACHU =
{ id = "SPRITE_PIKACHU", image = sheet, frames = 6, walker = true } }
local trailer = NPCFacade.new({ gen2Sprites = sprites }, "TEST_MAP", {
index = 241, name = "TRAILER_1", sprite = "SPRITE_PIKACHU",
movement = "STAY", range = "NONE", x = 3, y = 4,
})
eq(getmetatable(trailer), Gen2Npc, "the trailer is a real Gen 2 NPC, so the "
.. "Gen 2 draw list poses it")
eq(trailer.cellX, 3, "cell carried over")
eq(trailer.spriteId, "SPRITE_PIKACHU", "Gen 1's spriteId stamp is kept")
check(not trailer.fixedFacing, "STAY must not map to STILL: a trailer that "
.. "cannot turn is not a follower")
eq(trailer.kind, "stand", "and it never wanders off on its own")
eq(type(trailer.pose), "function", "Gen 1's pose contract exists to be wrapped")
-- ------- 4. Collision, Boxes, the map vocabulary
local Collision = Gen2Compat.resolve("src.world.Collision", "fixture")
eq(Collision.DELTA, Gen2Map.DELTA, "one DELTA table, not a copy")
local tx, ty = Collision.target(2, 2, "right")
eq(tx, 3, "target x") eq(ty, 2, "target y")
trailer.passable = true
eq(Collision.occupied({ trailer }, 3, 4, nil), nil,
"a passable entity never blocks a step")
local Boxes = Gen2Compat.resolve("src.pokemon.Boxes", "fixture")
local save = { currentBox = 2 }
local boxes = Boxes.ensure(save)
check(save.boxes ~= nil, "ensure MATERIALISES save.boxes, because Gen 1's "
.. "callers index it straight afterwards")
eq(#boxes, require("src.core.gen2.Boxes").NUM_BOXES, "all of Gold's boxes")
eq(Boxes.active(save), save.boxes[2], "active is the current box")
-- COUNT / CAPACITY used to be missing outright, so `for i = 1, Boxes.COUNT`
-- raised "'for' limit must be a number"
eq(Boxes.COUNT, require("src.core.gen2.Boxes").NUM_BOXES, "Boxes.COUNT")
eq(Boxes.CAPACITY, require("src.core.gen2.Boxes").MONS_PER_BOX, "Boxes.CAPACITY")
-- the inherited Gen 2 deposit(save, partyIndex, boxIndex) would index
-- save.party with a MON TABLE, return false plus "There is no POKeMON there."
-- and drop the mon -- which reads exactly like "every box is full"
local mon = { species = "PIDGEY" }
eq(Boxes.deposit(save, mon), 2, "deposit takes a MON and answers a box number")
eq(save.boxes[2][1], mon, "and the mon is in the save, not in a detached table")
-- ensure clamps currentBox, which is load bearing: Boxes2.box hands back a
-- fresh DETACHED table for an index outside 1..NUM_BOXES
local wild = { currentBox = 99 }
local wildBoxes = Boxes.ensure(wild)
eq(wild.currentBox, Boxes.COUNT, "ensure clamps currentBox into range")
eq(Boxes.active(wild), wildBoxes[Boxes.COUNT], "so active is a box in the save")
eq(type(Gen2Map.isCounterCell), "function", "Map:isCounterCell exists")
eq(type(Gen2Map.warpAtCell), "function", "Map:warpAtCell exists")
check(Gen2Map.isOutside({ environment = "TOWN" }), "a town is outside")
check(not Gen2Map.isOutside({ environment = "INDOOR" }), "a house is not")
eq(type(Player.facingCell), "function", "Player:facingCell exists")
-- the Gen 1 module-level statics, which a facade could not have served
-- because a mod calls them on world.map
for _, name in ipairs({ "blockAt", "setBlock", "tileAt", "isDoorTileCell",
"isWarpTileCell", "signAtCell" }) do
eq(type(Gen2Map[name]), "function", "Map:" .. name .. " exists")
end
check(Gen2Map.isOutdoor({ environment = "ROUTE" }), "a route is outdoor")
check(Gen2Map.inRegion({ id = "GOLDENROD_CITY" }, nil, "GOLDENROD"),
"inRegion falls back to the id prefix, which is honest on either cache")
-- a sprite-name test would answer false for every real boulder on Gold
check(Gen2Map.isPushable({ movement = 0x19 }), "STRENGTH_BOULDER is pushable")
check(not Gen2Map.isPushable({ sprite = "SPRITE_BOULDER" }),
"and the Gen 1 sprite name is not what decides it")
-- absent, not answered: Gold's fly points are landmark spawns and its ghost
-- battles are Kanto content
eq(Gen2Map.isFlyTown, nil, "Map.isFlyTown stays absent on Gold")
eq(Gen2Map.ghostBattles, nil, "Map.ghostBattles stays absent on Gold")
-- ------- 4b. FieldDefaults answers only what Gold genuinely shares
local FD = Gen2Compat.resolve("src.world.FieldDefaults", "fixture")
eq(FD.CONSTANTS.world.stepFrames, 16, "stepFrames is 16 on both generations")
eq(FD.CONSTANTS.world.turnFrames, 4, "and turnFrames is 4")
eq(FD.CONSTANTS.encounterBuckets, nil,
"the Gen 1 wild-slot spread is Kanto and must not roll on Gold")
eq(FD.CONSTANTS.hmBadges, nil, "nor may Kanto's HM badge gates")
eq(FD.world(nil, "stepFrames"), 16, "world() answers the shared keys")
eq(FD.world(nil, "poisonStepInterval"), nil,
"and refuses the ones Gold's StepEvents do not read from a table")
eq(FD.FIELD, nil, "FIELD is absent: every leaf of it is Kanto")
eq(FD.seed({}), nil, "seed never writes Kanto's field record into a Gold cache")
-- Gen 1's fieldValue is VARIADIC; a fixed arity dropped every deeper path
eq(FD.fieldValue(nil, "playerSprites", "walk"), World.PLAYER_SPRITE,
"the one data.field path Gold can answer")
eq(FD.fieldValue(nil, "playerSprites"), nil,
"and not the whole table, which a mod would index .surf on")
-- ------- 5. the follower's shouldSpawn upvalue, by name
local function upvalueIndex(fn, wanted)
local i = 1
while true do
local name = debug.getupvalue(fn, i)
if not name then return nil end
if name == wanted then return i end
i = i + 1
end
end
local updateIdx = upvalueIndex(Follower.update, "shouldSpawn")
local enterIdx = upvalueIndex(Follower.onMapEntered, "shouldSpawn")
check(updateIdx ~= nil, "Follower.update closes over a local named shouldSpawn")
check(enterIdx ~= nil, "and so does Follower.onMapEntered")
-- the supported way in writes the SAME cell, so a mod that uses the setter and
-- one that rewrites the upvalue cannot end up with two different predicates
local sentinel = function() return false end
local restore = Follower.setShouldSpawn(sentinel)
eq(select(2, debug.getupvalue(Follower.update, updateIdx)), sentinel,
"setShouldSpawn writes the upvalue debug.setupvalue would have")
Follower.setShouldSpawn(restore)
-- ------- 6. patch it and the follower spawns, trails and survives a rebuild
local function fixtureWorld()
local game = { data = {}, save = { party = {} } }
local world = World.new(game)
world.maps = {
TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 4, height = 4,
blocks = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 },
objects = {}, warps = {} },
}
-- every cell walkable: one block id whose whole quad is COLL_FLOOR
local tileset = { collision = { [2] = { 0, 0, 0, 0 } } }
world.map = Gen2Map.new(world.maps.TEST_MAP, tileset)
world.sprites = { SPRITE_PIKACHU =
{ id = "SPRITE_PIKACHU", image = sheet, frames = 6, walker = true } }
world.player = Player.new(4, 4, "down",
{ id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true })
world.npcs, world.entities, world.ghosts = {}, { world.player }, {}
return world, game
end
local world, game = fixtureWorld()
local always = function() return true end
debug.setupvalue(Follower.update, updateIdx, always)
-- the two closures share one upvalue cell under 5.1, which is what lets a mod
-- patch either one and suppress both -- assert it rather than assume it
eq(select(2, debug.getupvalue(Follower.onMapEntered, enterIdx)), always,
"update and onMapEntered share the shouldSpawn cell")
Follower.onMapEntered(game, world, nil, true)
local npc = Follower.current(world)
check(npc ~= nil, "the follower spawns once shouldSpawn says yes")
check(npc.passable, "and never blocks the player")
eq(npc.cellX, 4, "parked on the player for a fresh map load")
-- Two committed steps right. The first hands the follower the cell it is
-- already standing on (it spawned under the player), the second is the one it
-- has to walk -- and the goal is taken on the COMMIT, while targetX is still
-- set, not on the landing, which is what keeps the gap at one cell.
local function commit(tx)
world.player.targetX, world.player.moving = tx, true
Follower.update(game, world)
world.player.cellX, world.player.targetX = tx, nil
world.player.moving = false
end
commit(5)
commit(6)
eq(npc.goalX, 5, "the goal is the cell the player VACATED, one behind")
check(npc.moving, "and the follower took a step toward it")
eq(npc.targetX, 5, "one cell, in the right direction")
eq(npc.facing, "right", "facing the way it walks")
-- a seamless rebuild is what used to wipe a mod-inserted entity
world.map.def.objects = {}
world:rebuildPeople({ seamless = true })
check(Follower.current(world) ~= nil,
"a guest survives rebuildPeople, so a follower does not vanish at the top "
.. "of the hour")
eq(#world.npcs, 1, "and is listed exactly once")
-- ------- 7. World:step ticks it, World:interact dispatches through the facade
local ticked = 0
world.stepBody = function() end
local realUpdate = Follower.update
Follower.update = function() ticked = ticked + 1 end
world:step()
Follower.update = realUpdate
eq(ticked, 1, "World:step calls Follower.update after the body, which is the "
.. "one per-frame driver every Gen 1 follower mod wraps")
local OC = Gen2Compat.resolve("src.world.OverworldController", "fixture")
world.interactBody = function() return "vanilla" end
eq(world:interact(), "vanilla", "with nothing patched, interact is the body")
local vanillaInteract = OC.interact
OC.interact = function(w) return "wrapped:" .. tostring(vanillaInteract(w)) end
eq(world:interact(), "wrapped:vanilla",
"a replaced OverworldController.interact is what the A press dispatches to")
OC.interact = vanillaInteract
local owTicks = 0
local vanillaUpdate = OC.update
OC.update = function() owTicks = owTicks + 1 end
world:step()
OC.update = vanillaUpdate
eq(owTicks, 1, "and a replaced OverworldController.update ticks once a frame")
world:step()
eq(owTicks, 1, "restored, it costs one comparison and does not run")
-- talkTo is the other dispatch a follower mod wraps, and it used to be a warn
-- saying the wrapper would never run
local talked = nil
world.player.facing = "down"
world.player.cellX, world.player.cellY = 4, 4
local guest = Gen2Npc.new("TEST_MAP", { index = 9, x = 4, y = 5,
movement = Gen2Npc.MOVE.STANDING_UP },
{ id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true })
table.insert(world.npcs, guest)
world.interactBody = nil
world.busy = function() return false end
world.vm = { lastTalked = 0, start = function() return true end,
running = function() return false end }
local vanillaTalk = OC.talkTo
OC.talkTo = function(_w, npc) talked = npc return true end
eq(world:interactBody(), true, "a replaced talkTo intercepts the A press")
eq(talked, guest, "and receives the object Gold resolved")
OC.talkTo = vanillaTalk
-- ------- 8. the follower's three general members, which were nil calls
Follower.onMapEntered(game, world, nil, true)
local trailer = Follower.current(world)
check(trailer ~= nil, "a follower to hide")
eq(Follower.at(world, trailer.cellX, trailer.cellY), trailer,
"Follower.at finds a standing follower, which is the interact hook's test")
trailer.moving = true
eq(Follower.at(world, trailer.cellX, trailer.cellY), nil,
"and not a moving one, which is between two cells")
trailer.moving = false
local drawn = 0
for _, e in ipairs(world.entities) do if e == trailer then drawn = drawn + 1 end end
eq(drawn, 1, "it is on the draw list to begin with")
Follower.setVisible(world, false)
drawn = 0
for _, e in ipairs(world.entities) do if e == trailer then drawn = drawn + 1 end end
eq(drawn, 0, "setVisible(false) drops it from the DRAW list")
check(Follower.current(world) == trailer,
"and leaves it in the UPDATE list, so it hides in place and keeps trailing")
Follower.setVisible(world, true)
Follower.setVisible(world, true)
drawn = 0
for _, e in ipairs(world.entities) do if e == trailer then drawn = drawn + 1 end end
eq(drawn, 1, "and re-adding is idempotent rather than doubling the entity")
-- the Gen 1 name for the trail, by reference: a reset through either name has
-- to move the live one
check(world.pikachuTrail == world.followerTrail,
"ow.pikachuTrail and world.followerTrail are one table")
-- ------- 9. the NPC instance surface Gen 1 mods pose and draw through
local posed = { trailer:pose() }
eq(#posed, 7, "pose keeps Gen 1's seven-value contract")
eq(posed[4], trailer.facing, "facing is the fourth")
eq(type(Gen2Npc.marching), "nil", "marching is a FIELD, not a method")
-- Gen 2 saw moving with no targetX, sat still, then assigned cellX = nil and
-- every later read blew up a frame downstream
trailer.marching, trailer.progress, trailer.stepFrames = true, 0, 2
trailer.moving = false
local wasX, wasY = trailer.cellX, trailer.cellY
trailer:update(world.map, world.entities)
check(trailer.moving, "a marching NPC animates in place")
trailer:update(world.map, world.entities)
eq(trailer.cellX, wasX, "and never leaves its cell")
eq(trailer.cellY, wasY, "on either axis")
check(not trailer.marching, "the cycle ends itself after one step's frames")
trailer.stepFrames = nil
-- ------- 10. Collision.canMove answers what Gold's own player is told
-- the surf exception: without it the facade tells a surfing mod every water
-- cell is blocked, one line before Gold rides onto it. 0x20 is COLL_WATER's
-- row in Permissions' table.
local waterMap = Gen2Map.new(world.maps.TEST_MAP,
{ collision = { [2] = { 0x20, 0x20, 0x20, 0x20 } } })
local walker = { cellX = 1, cellY = 1 }
local surfer = { cellX = 1, cellY = 1, surfing = true }
eq(select(2, Collision.canMove(waterMap, {}, walker, "right")), "tile",
"water refuses a walker")
check(Collision.canMove(waterMap, {}, surfer, "right"),
"and carries a surfer, which map:isWalkableCell alone never says")
-- GetMovementPermissions' side-wall rule (Map:stepPermitted): a facade that
-- omits it says yes where Gold bumps, which is what ends an Ice Path slide
local stubMap = {
inBounds = function() return true end,
isWalkableCell = function() return true end,
cellCollision = function() return 0 end,
stepPermitted = function() return false end,
}
eq(select(2, Collision.canMove(stubMap, {}, walker, "up")), "tile",
"and a step the neighbour's wall kind forbids is refused as 'tile'")
-- a mod's OWN movement.collision hook has to see its own canMove call
local Runtime = require("src.mods.Runtime")
local realHooks = Runtime.hooks
local seen = nil
Runtime.hooks = {
chains = { ["movement.collision"] = true },
call = function(_self, name, vanilla, allowed, ctx)
if name ~= "movement.collision" then return vanilla(allowed, ctx) end
seen = ctx
return not allowed
end,
}
check(Collision.canMove(waterMap, {}, walker, "right"),
"the movement.collision chain runs inside the facade's canMove")
eq(seen and seen.reason, "tile", "with the ctx keys both generations use")
eq(seen and seen.toX, 2, "including the target cell")
Runtime.hooks = realHooks
eq(Collision.load({}), nil,
"Collision.load is a NAMED no-op, never a silent accept: Gold has no "
.. "tile-pair table for the mod's intent to land in")
-- ------- 11. ScriptRunner: the pure half forwards, the rest is a handle
local SR = Gen2Compat.resolve("src.script.ScriptRunner", "fixture")
eq(SR.scanLabels({ { "label", "top" }, { "wait", 1 } }).top, 1,
"scanLabels is the real Gen 1 function, pure over the mod's own rows")
-- with Gen 1's default lookup a script of show_text / wait / warp validates
-- CLEAN on Gold and then every row is skipped at run time
liveGame.data.commands = { ["fixture:beep"] = {} }
eq(#SR.validate({ { "show_text", "x" } }), 1,
"validate resolves against Gold's OWN command registry, so a Gen 1 built-in "
.. "is reported rather than passed")
eq(#SR.validate({ { "fixture:beep" } }), 0, "and a registered mod verb passes")
eq(SR.new(nil, world).isRunning, SR.new(nil, world).isRunning,
"the handle carries the query half of the one world.vm")
eq(SR.new(nil, world):resume(), nil, "resume is refused, not forwarded: the "
.. "World already drives the VM and a second call dispatches twice")
eq(SR.new(nil, world):update(), nil,
"and so is update, which would double-decrement every pause")
-- ow.runner, the field a mod guards on before acting. nil there is FALSEY,
-- so a mod concludes NO SCRIPT IS RUNNING while one is.
check(world.runner ~= nil, "the World carries a runner shim")
eq(world.runner:isRunning(), world:scriptRunning(),
"and answers the Gen 1 query")
-- ------- 12. a monkey-patch on a proxied class reads back as ITSELF
--
-- The write-through proxy answered its own override first, so
-- `PartyMenu.new = wrapper` read back as the facade's function (the patch was
-- invisible) and the override then called the class member the write had
-- already replaced -- straight back into the wrapper.
local Party2 = require("src.ui.gen2.PartyMenu")
local PM = Gen2Compat.resolve("src.ui.PartyMenu", "fixture")
local facadeNew = PM.new
local wrapped = 0
local wrapper = function(...) wrapped = wrapped + 1 return facadeNew(...) end
PM.new = wrapper
check(rawequal(PM.new, wrapper), "a mod's write to a proxied member reads "
.. "back as the mod's own value, so the patch is visible")
check(rawequal(Party2.new, wrapper),
"and still lands on the class Gold pushes")
liveGame.stack.top = function() return nil end
liveGame.stack.pop = function() end
local switched = nil
local pressed = {}
liveGame.input = { wasPressed = function(_, b) return pressed[b] end }
local pidgey = { species = "PIDGEY" }
local menu = PM.new(liveGame, { party = { pidgey },
onSwitch = function(mon) switched = mon end })
eq(wrapped, 1, "the wrapper ran exactly once: the override calls the "
.. "constructor it CAPTURED, not the patched class member")
PM.new = nil
eq(PM.new, nil, "a nil write is honoured rather than resurrecting the override")
PM.new = facadeNew
-- Gen 1 fires onSwitch on A for this construction (src/ui/PartyMenu.lua:569);
-- Gold's field submenu swallowed the press and only CANCEL could ever answer.
check(not menu.wantsSubmenu, "onSwitch outside battle builds the DIRECT list")
pressed.a = true
menu:update(1 / 60)
eq(switched, pidgey, "and A on the row fires onSwitch with the mon")
liveGame.input = nil
-- the same shape on the battle screen, whose overrides are stamped onto Gold's
-- class so an instance answers them
local BS = Gen2Compat.resolve("src.battle.BattleState", "fixture")
local Battle2 = require("src.ui.gen2.BattleState")
local facadeSay = BS.say
local sayPatch = function(self, text) return facadeSay(self, text) end
BS.say = sayPatch
check(rawequal(BS.say, sayPatch), "BattleState.say reads back as the patch")
check(rawequal(Battle2.say, sayPatch), "and an instance dispatches to it")
BS.say = facadeSay
-- ------- 13. the overworld facade over a LIVE world
local ow = Gen2Compat.resolve("src.world.OverworldController", "fixture")
local owWorld = fixtureWorld()
owWorld.map.def.tileset = "TS"
owWorld.maps.OTHER_MAP = { id = "OTHER_MAP", group = 1, map = 3, width = 4,
height = 4, blocks = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 },
objects = {}, warps = {}, tileset = "TS" }
owWorld.tilesets = { TS = { collision = { [2] = { 0, 0, 0, 0 } } } }
-- Map copies def.connections at construction (src/world/gen2/Map.lua:26)
owWorld.map.connections = { west = { map = "OTHER_MAP", offset = 0 } }
liveGame.world = owWorld
-- objectId 0 is the PLAYER and 1 is wLastTalked (World:objectEntity): the old
-- `(def.index or 0) + 1` gave the player -- which has no .def -- objectId 1,
-- so the canonical Gen 1 call walked the last-talked NPC and returned true.
local talkNpc = Gen2Npc.new("TEST_MAP", { index = 1, x = 1, y = 1,
movement = Gen2Npc.MOVE.STANDING_UP },
{ id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true })
table.insert(owWorld.npcs, talkNpc)
owWorld.talkNpc = talkNpc
check(ow.scriptMove(owWorld.player, "up", 1), "scriptMove takes the player")
eq(owWorld.moveState.objectId, 0, "as objectId 0, not the last-talked NPC")
owWorld.moveState = nil
check(ow.scriptMove(talkNpc, "up", 1), "and a mapped object")
eq(owWorld.moveState.objectId, 2, "as def.index + 1")
owWorld.moveState = nil
local moved, why = ow.scriptMove({ cellX = 1, cellY = 1 }, "up", 1)
eq(moved, nil, "an entity with neither is REFUSED, never moved by proxy")
check(type(why) == "string" and #why > 0, "with a reason")
eq(ow.npcByIndex(1), talkNpc, "npcByIndex maps the Gen 1 index to index + 1")
eq(ow.npcByIndex(0), nil, "and index 0 -- which names no object on either "
.. "generation -- is nil, not Gold's wLastTalked")
-- Gen 1 returns dest DEF, tileset def, x, y, conn
-- (src/world/OverworldController.lua:1404); three values with a map ID first
-- shifted every name in `local dest, ts, x, y = ...`
eq(select("#", ow.connectionLanding("left")), 5,
"connectionLanding keeps Gen 1's five-value shape")
local dest, ts, cx, cy, conn = ow.connectionLanding("left")
eq(type(dest), "table", "dest is the map DEF, so dest.width reads")
eq(dest.width, 4, "with the destination's own size")
check(Gen2Map.defPassable(dest, ts, cx, cy, false),
"and the tileset def is the second value, which is what the very next "
.. "Map.defPassable call takes")
eq(type(conn), "table", "the connection record is the fifth")
-- COVERAGE has to match what a read actually answers: these eight were
-- published backed and every one of them read nil
local owCoverage = Gen2Compat.coverage("src.world.OverworldController")
for member, status in pairs(owCoverage.members) do
if status == "backed" then
check(ow[member] ~= nil,
"published backed and answers: OverworldController." .. member)
end
end
eq(ow.player, owWorld.player, "ow.player is the live player, the way Gen 1's "
.. "module IS the live state (src/core/Game.lua:87)")
eq(ow.map, owWorld.map, "and ow.map the live map")
eq(ow.npcs, owWorld.npcs, "and the lists are the world's own")
local swap = Player.new(1, 1, "down",
{ id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true })
ow.player = swap
eq(owWorld.player, swap, "a write to a live name moves the world, not a "
.. "shadow copy on the facade")
ow.player = owWorld.player
-- Gold's neighbour rows carry `id`, Gen 1's carry `map`: answered, every
-- nb.map read is nil and a scan matches nothing
eq(ow.neighbors, nil, "neighbors answers nil rather than a list of the wrong "
.. "shape")
eq(Gen2Compat.memberStatus("src.world.OverworldController", "neighbors"),
"warned", "and coverage says so")
eq(Follower.shouldSpawn, nil, "Follower.shouldSpawn is a file-local")
eq(Gen2Compat.memberStatus("src.world.PikachuFollower", "shouldSpawn"),
"absent", "so coverage publishes it absent, with setShouldSpawn as the way in")
-- leave the process as we found it: these tables are singletons
debug.setupvalue(Follower.update, updateIdx, function() return false end)
S.finish()
+17 -22
View File
@@ -102,34 +102,29 @@ end
-- (M14 adds the gate; it does not retro-fit other milestones' unit tests).
-- Removing a name from this list is the only way to close its entry, and
-- the staleness check below forces that the moment a test lands.
--
-- The Gen 2 tier drained most of this ledger: tests/engine/gate_gen2_mod_api.lua
-- names each seam Gold raises and holds it to having a call site in BOTH
-- generations under the one shared name, which is a test that fails if the
-- seam is deleted or renamed. It is not a payload case, so a per-seam case
-- through the public mod API is still worth writing for those names -- it is
-- simply no longer owed as debt, because the ledger's rule is that an entry
-- closes the moment any test names the seam.
local DEBT = {
-- M6 audio: the registry is exercised through cries/music/sfx, never by
-- the aggregate `audio` name
["registry:audio"] = "M6",
-- M12 link: declared for the extra-bag negotiation, no case names it yet
["registry:link_fields"] = "M12",
["hook:encounter.fishing"] = "M5",
["hook:render.zones"] = "M9",
["hook:trainer.party"] = "M7",
["hook:ui.pc.items"] = "M8",
-- registry:link_fields closed the same way: the Gen 2 gate names it in the
-- gated list, because link play is Gen 1 only and Gold has nowhere to put a
-- link field. Still not a payload case, still worth one.
--
-- hook:render.zones was the last M9 entry and closed when Gold grew its own
-- zone pass: src/core/Game2.lua:blitZones raises the hook with the same rect
-- list shape src/render/Renderer.lua does, so gate_gen2_mod_api names it in
-- the shared-hook list and the ledger's rule ("an entry closes the moment
-- any test names the seam") retired it.
["event:link.connected"] = "M12",
["event:link.ended"] = "M12",
["event:player.warped"] = "M5",
["event:pokemon.before_give"] = "M7",
["event:pokemon.evolved"] = "M7",
["event:pokemon.level_up"] = "M7",
["event:pokemon.move_learned"] = "M7",
["event:save.loaded"] = "M11",
["event:save.loading"] = "M11",
["event:save.writing"] = "M11",
["event:trade.completed"] = "M12",
["event:world.blacked_out"] = "M5",
["event:world.boulder_moved"] = "M5",
["event:world.interacted"] = "M5",
["event:world.npc_spawned"] = "M5",
["event:world.trainer_engaged"] = "M5",
}
local seen = {}
+3
View File
@@ -45,6 +45,9 @@ local ALLOWED = {
.. "(POKEPORT_LAUNCHER_PROF's frame timings)" },
{ pattern = '== "\\v"', why = "comparing against a marker, not printing it" },
{ pattern = "txBuf", why = "newline-delimited wire framing, not text" },
{ pattern = "local PAGE, SCROLL, LINE",
why = "naming the three text-control markers so the code that splits a "
.. "decoded stream into pages can compare against them" },
}
-- Whole files inside a watched directory that are exempt, with the reason.
+369
View File
@@ -0,0 +1,369 @@
-- The six Gen 2-only content registries, end to end: held_items,
-- phone_contacts, decorations, apricorns, landmarks and radio_channels.
--
-- Three claims, and the third is the one that makes the other two worth
-- anything:
--
-- 1. the routing is symmetrical. Schemas.GEN2 says where a shared registry
-- lands on Gold; Schemas.GEN1 is its mirror, and these six are gated
-- under GEN ONE -- Red has no phone, no radio and no held items, so a
-- write there is taken, dropped and reported rather than merged into a
-- namespace nothing on Red would read.
-- 2. the vanilla records are the literals they replaced, byte for byte. A
-- registry that seeds a different record than the module used to hold is
-- a behaviour change wearing a refactor's clothes.
-- 3. the CONSUMER reads through the registry. A registry nothing reads is
-- the silent no-op this whole design exists to prevent, so each one is
-- driven from a mod's own registration through to the routine the game
-- calls: Decorations.attributes, Phone.CONTACTS, Apricorns.ballFor,
-- Nests.landmarkId, MapRadio.channelRecord and, for held_items, the
-- write-back onto data.items that src/battle/gen2/Battle.lua:heldEffect
-- reads.
--
-- ROM-free: the generation is injected through the loader (T.sdk.loadMods
-- opts.generation), the way tests/engine/gate_gen2_mod_api.lua does.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Schemas = require("src.mods.Schemas")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Phone = require("src.core.gen2.Phone")
local Decorations = require("src.core.gen2.Decorations")
local Apricorns = require("src.core.gen2.Apricorns")
local Nests = require("src.core.gen2.Nests")
local MapRadio = require("src.ui.gen2.MapRadio")
local NAMES = { "held_items", "phone_contacts", "decorations", "apricorns",
"landmarks", "radio_channels" }
local PATHS = {
held_items = "gen2HeldItems",
phone_contacts = "gen2PhoneContacts",
decorations = "gen2Decorations",
apricorns = "gen2Apricorns",
landmarks = "gen2Landmarks.landmarks",
radio_channels = "gen2RadioChannels",
}
-- ------- 1. the mirror of Schemas.GEN2
for _, name in ipairs(NAMES) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "the catalog declares it: " .. name)
T.eq(spec.target, nil,
"a Gen 2-only registry carries no Gen 1 target: " .. name)
T.eq(Schemas.targetFor(name, spec, 1), nil,
"and none is invented for Gen 1: " .. name)
T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), PATHS[name],
"routed to its Gen 2 path: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
T.check(spec.example ~= nil, "and documents a call: " .. name)
end
-- the mirror holds in the other direction too: nothing shared is gated under
-- Gen 1, which is what would break if a routing row were put in the wrong
-- table
for name, spec in pairs(Schemas.REGISTRIES) do
if Schemas.GEN1[name] == nil then
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
"a registry with no Gen 1 routing row keeps its target: " .. name)
T.eq(Schemas.gatedFor(name, 1), false,
"and is not gated under Gen 1: " .. name)
end
end
for name in pairs(Schemas.GEN1) do
T.check(Schemas.REGISTRIES[name] ~= nil,
"Schemas.GEN1 names a real registry: " .. name)
end
-- ------- the Gen 2 dataset these merge into
--
-- data.gen2Constants.phoneContactOrder is the phone id space, verbatim from
-- the ROM manifest (the four PHONE_UNUSED rows are the const_skip holes).
local PHONE_ORDER = {
"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM",
"PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY",
"PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED",
"PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH",
"PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY",
"PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ",
"PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA",
"PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN",
"PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD",
"PHONE_POKEFANM_DEREK", "PHONE_FISHER_CHRIS", "PHONE_POKEMANIAC_BRENT",
"PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON",
"PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN",
}
-- two landmark records in the cache's own shape (index = the map header byte)
local function landmarkTable()
return {
order = { "LANDMARK_SPECIAL", "LANDMARK_FIX_TOWN" },
landmarks = {
LANDMARK_SPECIAL = { id = "LANDMARK_SPECIAL", name = "SPECIAL",
x = 0, y = 0, index = 0 },
LANDMARK_FIX_TOWN = { id = "LANDMARK_FIX_TOWN", name = "FIX\nTOWN",
x = 4, y = 5, index = 1 },
},
}
end
local function goldData()
local data = T.fixtures.fresh()
-- an item that holds something, so held_items has a row to seed from
data.items.FIX_LEFTOVERS = {
id = "FIX_LEFTOVERS", index = 90, name = "FIX LEFTOVERS", price = 0,
heldEffect = "HELD_LEFTOVERS", heldParameter = 0,
}
data.items.RED_APRICORN = { id = "RED_APRICORN", index = 91,
name = "RED APRICORN", price = 0 }
data.items.LEVEL_BALL = { id = "LEVEL_BALL", index = 92, ball = true,
name = "LEVEL BALL", price = 0 }
data.items.ULTRA_BALL = { id = "ULTRA_BALL", index = 93, ball = true,
name = "ULTRA BALL", price = 0 }
-- the fixture maps under the key Gold keeps them at, so a contact's `map`
-- resolves against the same id space src/world/gen2/World.lua walks
data.gen2Maps = data.maps
data.gen2Constants = { phoneContactOrder = PHONE_ORDER }
data.gen2Landmarks = landmarkTable()
-- src/core/Game2.lua:load builds this before mods:load; the harness
-- stands in for that boot step
data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items)
return data
end
local function memfsFor(body, extra)
local manifest = [[{
"id": "fix_gen2_content",
"name": "Fixture Gen 2 Content",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true
}]]
local files = { ["mods/fix_gen2_content/manifest.json"] = manifest,
["mods/fix_gen2_content/main.lua"] = body }
for path, text in pairs(extra or {}) do files[path] = text end
return T.sdk.memfs(files)
end
-- ------- 2. parity: the seeded record IS the literal
--
-- Compared field by field against the module's own table rather than against
-- a copy of it, so a record that gained or lost a key fails here.
local function sameRecord(got, want, label)
if type(got) ~= "table" or type(want) ~= "table" then
return T.eq(got, want, label)
end
local ok = true
for key, value in pairs(want) do
if got[key] ~= value then ok = false end
end
for key in pairs(got) do
if want[key] == nil then ok = false end
end
return T.check(ok, label)
end
do
local run = T.sdk.loadNone({ data = goldData(), generation = 2 })
T.eq(#run.errors, 0, "a zero-mod Gold load reports no errors")
local decorations = run.data.gen2Decorations
T.check(decorations ~= nil, "the decorations merge target appears")
local rows = 0
for decoId, attr in pairs(Decorations.ATTRIBUTES) do
rows = rows + 1
sameRecord(decorations[Decorations.idFor(decoId)], attr,
"vanilla decoration is the attribute row: deco:" .. decoId)
end
T.eq(rows, 53, "every attribute row is registered")
local contacts = run.data.gen2PhoneContacts
T.check(contacts ~= nil, "the phone_contacts merge target appears")
sameRecord(contacts.PHONE_YOUNGSTER_JOEY, Phone.CONTACTS[15],
"vanilla contact is the PhoneContacts row: PHONE_YOUNGSTER_JOEY")
sameRecord(contacts.PHONE_MOM, Phone.CONTACTS[1],
"and the non-trainer rows too: PHONE_MOM")
T.eq(contacts.PHONE_UNUSED, nil,
"the const_skip holes are not registered under one shared id")
local apricorns = run.data.gen2Apricorns
T.check(apricorns ~= nil, "the apricorns merge target appears")
sameRecord(apricorns.RED_APRICORN, Apricorns.row("RED_APRICORN"),
"vanilla apricorn is the ApricornBalls row: RED_APRICORN")
T.eq(apricorns.RED_APRICORN.ball, "LEVEL_BALL",
"and it still hands back the LEVEL BALL")
local channels = run.data.gen2RadioChannels
T.check(channels ~= nil, "the radio_channels merge target appears")
T.eq(channels.OAKS_POKEMON_TALK and channels.OAKS_POKEMON_TALK.channel, 1,
"OAK's POKEMON TALK keeps its dial position")
T.eq(channels.ROCKET_RADIO and channels.ROCKET_RADIO.channel, 8,
"and ROCKET RADIO keeps its own")
-- held_items and landmarks merge onto a table that already existed, so the
-- claim there is that the merge left it exactly as it found it
T.eq(run.data.gen2HeldItems.FIX_LEFTOVERS.heldEffect, "HELD_LEFTOVERS",
"the held_items view still holds the item's own effect")
T.eq(ItemEffects.applyHeldItems(run.data,
ItemEffects.heldSnapshot(run.data.gen2HeldItems)), 0,
"and a mod-free merge writes nothing back onto data.items")
T.eq(run.data.gen2Landmarks.landmarks.LANDMARK_FIX_TOWN.x, 4,
"the landmark records are the cache's own")
run.release()
end
-- ------- 3. the consumers, driven from a mod's registration
do
local data = goldData()
local run = T.sdk.loadMods({ "mods/fix_gen2_content" }, {
fs = memfsFor([[
local mod = ...
-- an existing row edited, and a new one registered, for each registry
mod.content.decorations:patch("deco:2", { name = "COZY" })
mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY",
{ map = "FIX_ROUTE" })
mod.content.apricorns:override("RED_APRICORN",
{ apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600,
index = 1 })
mod.content.landmarks:patch("LANDMARK_FIX_TOWN", { x = 9 })
mod.content.landmarks:register("LANDMARK_MOD_ISLE",
{ id = "LANDMARK_MOD_ISLE", name = "MOD\nISLE", x = 1, y = 2,
index = 7 })
mod.content.radio_channels:register("PIRATE_RADIO",
{ channel = 9, name = "PIRATE RADIO" })
mod.content.held_items:patch("FIX_LEFTOVERS", { heldParameter = 7 })
]]),
data = data,
generation = 2,
})
T.eq(#run.errors, 0,
"the mod loads clean (" .. table.concat(run.errors, "; ") .. ")")
-- decorations: Decorations.attributes is the one read point every caller
-- (and src/ui/gen2/DecorationMenu.lua) comes through
Decorations.useRegistry(run.data)
T.eq(Decorations.attributes(2).name, "COZY",
"decorations: the merged row is what attributes() answers")
T.eq(Decorations.attributes(2).flag, Decorations.ATTRIBUTES[2].flag,
"and the fields the patch left alone are the cart's own")
T.eq(Decorations.name(2, nil), "COZY BED",
"so GetDecoName's port spells the merged row")
-- phone: the merged rows are folded onto the contact table every lookup in
-- src/core/gen2/Phone.lua keys by
Phone.useRegistry(run.data)
T.eq(Phone.CONTACTS[15].map, "FIX_ROUTE",
"phone_contacts: the merged row reaches the contact table")
T.eq(Phone.CONTACTS[15].class, "YOUNGSTER",
"and the untouched fields survive the patch")
-- the cache overlay runs from src/world/gen2/World.lua AFTER this, and must
-- not undo it
Phone.useExtracted({ phone = { [15] = { map = "ROUTE_30",
calleeTime = 7, callerTime = 7,
callee = "41:0001",
caller = "41:0002" } } })
T.eq(Phone.CONTACTS[15].map, "FIX_ROUTE",
"and the cache overlay does not undo it")
-- apricorns: Kurt hands back what the registry says he does
Apricorns.useRegistry(run.data)
T.eq(Apricorns.ballFor("RED_APRICORN"), "ULTRA_BALL",
"apricorns: the merged row is the ball Kurt makes")
T.eq(Apricorns.apricornFor("ULTRA_BALL"), "RED_APRICORN",
"and the reverse lookup follows it")
T.eq(#Apricorns.BALLS, 7, "the table is still the seven rows")
T.eq(Apricorns.BALLS[1].apricorn, "RED_APRICORN",
"in the ApricornBalls order the menu walks")
-- landmarks: the registry answers the map header's byte, including for an
-- index the extractor's `order` list has never heard of
T.eq(Nests.landmarkId(run.data, 1), "LANDMARK_FIX_TOWN",
"landmarks: a vanilla index still resolves")
T.eq(Nests.landmark(run.data, 1).x, 9,
"and the patch reaches the record the town map draws")
T.eq(Nests.landmarkId(run.data, 7), "LANDMARK_MOD_ISLE",
"a registered landmark resolves at its own index")
-- and a registered landmark cannot shadow a vanilla one by claiming its
-- byte: the cache's own row keeps its slot, so the answer does not depend
-- on pairs() order. A fresh table, because the index map is memoized per
-- landmarks table and the run's is already built.
local shadowed = landmarkTable()
shadowed.landmarks.LANDMARK_AAA_SHADOW = {
id = "LANDMARK_AAA_SHADOW", name = "AAA", x = 0, y = 0, index = 1,
}
T.eq(Nests.landmarkId({ gen2Landmarks = shadowed }, 1), "LANDMARK_FIX_TOWN",
"a second record at a taken index does not displace the cache's own")
local twoNew = landmarkTable()
twoNew.landmarks.LANDMARK_MOD_B = { id = "LANDMARK_MOD_B", name = "B",
x = 0, y = 0, index = 7 }
twoNew.landmarks.LANDMARK_MOD_A = { id = "LANDMARK_MOD_A", name = "A",
x = 0, y = 0, index = 7 }
T.eq(Nests.landmarkId({ gen2Landmarks = twoNew }, 7), "LANDMARK_MOD_A",
"and two registered records at one index resolve the same way every boot")
-- radio: a registered station is on the dial
local record, station = MapRadio.channelRecord(run.data, 9)
T.eq(station, "PIRATE_RADIO", "radio_channels: the new station is on the dial")
T.eq(record and record.name, "PIRATE RADIO", "with its own name")
T.eq(select(2, MapRadio.channelRecord(run.data, 8)), "ROCKET_RADIO",
"and the vanilla positions are unmoved")
-- held items: the merged row is written back onto the item record, which is
-- what src/battle/gen2/Battle.lua:itemDef reads
local applied = ItemEffects.applyHeldItems(run.data,
ItemEffects.heldSnapshot({ FIX_LEFTOVERS = { heldEffect = "HELD_LEFTOVERS",
heldParameter = 0 } }))
T.eq(applied, 1, "held_items: one item record changed")
T.eq(run.data.items.FIX_LEFTOVERS.heldParameter, 7,
"and the battle's own read sees the merged parameter")
T.eq(run.data.items.FIX_LEFTOVERS.heldEffect, "HELD_LEFTOVERS",
"with the effect the patch left alone")
T.eq(ItemEffects.heldItemFor("FIX_LEFTOVERS", run.data).heldParameter, 7,
"and heldItemFor answers from the merged table")
run.release()
-- module statics are process-wide; put them back before the next case
Phone.useRegistry(nil)
Decorations.useRegistry(nil)
end
-- ------- 4. the mirror case, through a real load
--
-- A Red boot takes the write, drops it and says so. Not fatal: a mod that
-- supports both games registers its Gold content unconditionally and should
-- still load the half that applies.
do
local run = T.sdk.loadMods({ "mods/fix_gen2_content" }, {
fs = memfsFor([[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", { catchRate = 77 })
mod.content.decorations:patch("deco:2", { name = "COZY" })
mod.content.radio_channels:register("PIRATE_RADIO", { channel = 9 })
]]),
generation = 1,
})
T.eq(run.data.pokemon.FIXMON_A.catchRate, 77,
"Gen 1: the shared registry still merged")
T.eq(run.data.gen2Decorations, nil,
"Gen 1: a Gen 2-only registry merges nothing")
T.eq(run.data.decorations, nil,
"Gen 1: and invents no namespace of its own")
local told = {}
for _, message in ipairs(run.errors) do
if message:match("decorations") then told.decorations = true end
if message:match("radio_channels") then told.radio = true end
end
T.check(told.decorations and told.radio,
"Gen 1: both dropped registrations are reported, not silent")
run.release()
end
T.finish("gen2_content_registries")
+651
View File
@@ -0,0 +1,651 @@
-- The events and hooks that are NEW IN GEN 2 -- the only names in the mod API
-- with no Gen 1 analogue, and therefore the only places a new name is
-- justified (docs/mod-api-gen2-compat.md, "New in Gen 2").
--
-- gate_gen2_mod_api.lua holds the SHARED names to having a call site in both
-- generations; by construction that gate cannot cover these, because a Gen 1
-- site is exactly what they do not have. This file is the other half: for
-- each new name, drive the real Gen 2 module through a live bus and assert the
-- payload the call site documents. It runs ROM-free -- every module below
-- takes its data by argument -- so it lives in the engine tier.
--
-- The discipline each case follows is the one gate_events/gate_hooks enforce
-- generally: subscribe, drive, assert the payload, unsubscribe, and assert the
-- mod-free path answers exactly what it answered before.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Events = require("src.mods.Events")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Apricorns = require("src.core.gen2.Apricorns")
local Breeding = require("src.core.gen2.Breeding")
local BugContest = require("src.core.gen2.BugContest")
local Clock = require("src.core.gen2.Clock")
local Happiness = require("src.core.gen2.Happiness")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
local Phone = require("src.core.gen2.Phone")
local PhoneRing = require("src.core.gen2.PhoneRing")
local Pokerus = require("src.core.gen2.Pokerus")
local Roamers = require("src.core.gen2.Roamers")
local Unown = require("src.core.gen2.Unown")
-- ------- the bus, installed the way Loader:load installs it
local events, hooks = Events.new(), Hooks.new()
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
Runtime.install(events, hooks, {})
-- Collect every payload `name` raises while `body` runs, then unsubscribe --
-- so the next case starts from the mod-free state and Runtime.wants goes back
-- to false, which is what the guarded call sites key off.
local function capture(name, body)
local seen = {}
local unsubscribe = events:on(name, function(payload)
seen[#seen + 1] = payload
end, 0, "gen2_new_seams")
body()
unsubscribe()
return seen
end
local function withHook(name, wrapper, body)
local remove = hooks:wrap(name, wrapper, 0, "gen2_new_seams")
local ok, err = pcall(body)
remove()
if not ok then error(err, 0) end
end
-- ------- a Gen 2 shaped dataset, small enough to read
local DATA = {
pokemon = {
growthRates = {
MEDIUM_FAST = { numerator = 1, denominator = 1 },
},
SEEDMON = {
id = "SEEDMON", name = "SEEDMON", index = 1, dex = 1,
types = { "GRASS" },
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
specialAttack = 65, specialDefense = 65 },
catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "SEED_TACKLE" } },
evolutions = {},
eggGroups = { "MONSTER" }, eggSteps = 20, genderRatio = 0x1f,
spriteFront = "a.png", spriteBack = "b.png", picSize = 5,
},
},
moves = { SEED_TACKLE = { id = "SEED_TACKLE", pp = 35 } },
}
-- ------- happiness.changed
do
local mon = { species = "SEEDMON", happiness = Happiness.BASE, hp = 20 }
local seen = capture("happiness.changed", function()
Happiness.change(mon, "GAINLEVEL")
end)
T.eq(#seen, 1, "happiness.changed fires once per ChangeHappiness")
T.eq(seen[1].mon, mon, "happiness.changed carries the mon")
T.eq(seen[1].event, "GAINLEVEL", "happiness.changed carries the event name")
T.eq(seen[1].reason, "event", "a ChangeHappiness reports reason 'event'")
T.eq(seen[1].from, Happiness.BASE, "happiness.changed carries the old value")
T.eq(seen[1].to, Happiness.BASE + 5, "happiness.changed carries the new value")
T.eq(seen[1].delta, 5, "happiness.changed's delta is what was applied")
-- the clamp is part of the delta: a mon on $ff gaining 5 gained nothing
local capped = { species = "SEEDMON", happiness = Happiness.MAX }
local clamped = capture("happiness.changed", function()
Happiness.change(capped, "GAINLEVEL")
end)
T.eq(clamped[1].delta, 0, "happiness.changed reports the clamped delta")
local save = { party = { { species = "SEEDMON", happiness = 10 } },
happinessStepCount = 1 }
local stepped = capture("happiness.changed", function()
Happiness.stepCycle(save)
end)
T.eq(#stepped, 1, "StepHappiness raises happiness.changed per mon it moved")
T.eq(stepped[1].reason, "step", "the walk reports reason 'step'")
T.eq(stepped[1].event, nil, "the walk has no HAPPINESS_* event")
-- the mod-free path is unchanged
local plain = { species = "SEEDMON", happiness = Happiness.BASE }
T.eq(Happiness.change(plain, "GAINLEVEL"), Happiness.BASE + 5,
"ChangeHappiness answers the same with nobody subscribed")
end
-- ------- breeding.compatibility, breeding.egg_created, egg.hatched
do
local man = { species = "SEEDMON", dvs = { attack = 1, defense = 2,
speed = 3, special = 4 }, otId = 1 }
local lady = { species = "SEEDMON", dvs = { attack = 5, defense = 6,
speed = 7, special = 8 }, otId = 2 }
-- vanilla: two same-gender SEEDMON with no Ditto never breed
T.eq(Breeding.compatibility(DATA, man, lady), 0,
"breeding.compatibility answers the vanilla 0 with nobody subscribed")
local ctxSeen
withHook("breeding.compatibility", function(nextFn, ctx)
ctxSeen = ctx
nextFn()
return 128
end, function()
T.eq(Breeding.compatibility(DATA, man, lady, { dayCare = true }), 128,
"breeding.compatibility replaces the answer")
end)
T.eq(ctxSeen.mon1, man, "breeding.compatibility's ctx carries the first mon")
T.eq(ctxSeen.mon2, lady, "breeding.compatibility's ctx carries the second")
T.eq(ctxSeen.data, DATA, "breeding.compatibility's ctx carries the data")
T.eq(ctxSeen.dayCare, true, "breeding.compatibility knows the yard called it")
-- and the value is clamped to the byte wBreedingCompatibility is
withHook("breeding.compatibility", function() return 9999 end, function()
T.eq(Breeding.compatibility(DATA, man, lady), 255,
"breeding.compatibility clamps to the compatibility byte")
end)
-- egg_created rides the same forced compatibility: initBreeding refuses 0
local save = { dayCare = { man = { mon = man }, lady = { mon = lady },
compatible = false },
player = { name = "GOLD", id = 7 } }
local created
withHook("breeding.compatibility", function() return 128 end, function()
created = capture("breeding.egg_created", function()
Breeding.initBreeding(DATA, save, { rng = function() return 200 end })
end)
end)
T.eq(#created, 1, "breeding.egg_created fires when the pair becomes compatible")
T.eq(created[1].compatibility, 128,
"breeding.egg_created carries the compatibility that let it run")
T.eq(created[1].stepsToEgg, save.dayCare.stepsToEgg,
"breeding.egg_created carries wStepsToEgg")
T.check(created[1].mother ~= nil and created[1].father ~= nil,
"breeding.egg_created names both parents")
T.check(created[1].mother ~= created[1].father,
"breeding.egg_created's parents are the two different records")
-- egg.hatched, off a real egg in a party slot
local egg = Mon.new(DATA, "SEEDMON", Breeding.EGG_LEVEL, { hp = 0 })
egg.isEgg = true
egg.eggSteps = 0
local file = { party = { egg } }
local hatched = capture("egg.hatched", function()
Breeding.hatch(DATA, file, 1, "SPROUT")
end)
T.eq(#hatched, 1, "egg.hatched fires once per hatched slot")
T.eq(hatched[1].slot, 1, "egg.hatched carries the party slot")
T.eq(hatched[1].species, "SEEDMON", "egg.hatched carries the species")
T.eq(hatched[1].nickname, "SPROUT", "egg.hatched carries the chosen nickname")
T.eq(hatched[1].egg, egg, "egg.hatched carries the egg it replaced")
T.eq(hatched[1].mon, file.party[1],
"egg.hatched carries the hatchling now in the party")
T.eq(hatched[1].mon.isEgg, nil, "the hatchling is no longer an egg")
end
-- ------- phone.call_received and phone.contact_list
do
local call = { kind = "call", contact = 1, direction = "incoming",
scriptKey = "41:4000" }
local seen = capture("phone.call_received", function()
PhoneRing.script(call, "JOEY", "YOUNGSTER")
end)
T.eq(#seen, 1, "phone.call_received fires once per ring")
T.eq(seen[1].call, call, "phone.call_received carries the descriptor")
T.eq(seen[1].contact, 1, "phone.call_received carries the contact id")
T.eq(seen[1].name, "JOEY", "phone.call_received carries the caller name")
T.eq(seen[1].className, "YOUNGSTER", "phone.call_received carries the class")
T.eq(seen[1].scriptKey, "41:4000", "phone.call_received carries the script key")
local save = {}
Phone.addContact(save, Phone.CONTACTS[1] and 1 or 1)
local vanilla = Phone.contacts(save)
T.eq(#vanilla, Phone.CONTACT_LIST_SIZE,
"the phone book is ten slots with nobody subscribed")
withHook("phone.contact_list", function(nextFn, file, list)
T.eq(file, save, "phone.contact_list is handed the save")
T.eq(#list, Phone.CONTACT_LIST_SIZE,
"phone.contact_list is handed all ten slots")
local out = nextFn()
out[10] = 1
return out
end, function()
local hooked = Phone.contacts(save)
T.eq(#hooked, Phone.CONTACT_LIST_SIZE,
"phone.contact_list keeps the slot count")
T.eq(hooked[10], 1, "phone.contact_list can fill an empty slot")
end)
-- a chain that returns the wrong shape is ignored rather than trusted
withHook("phone.contact_list", function() return { 1, 2 } end, function()
T.eq(#Phone.contacts(save), Phone.CONTACT_LIST_SIZE,
"a short phone.contact_list answer is refused")
end)
-- and an id the contact table does not know is blanked, not carried
withHook("phone.contact_list", function(nextFn)
local out = nextFn()
out[1] = 9999
return out
end, function()
T.eq(Phone.contacts(save)[1], 0,
"phone.contact_list blanks an unknown contact id")
end)
end
-- ------- clock.day_changed
do
local save = {}
local seen = capture("clock.day_changed", function()
-- the first read after a boot has nothing to compare against
Clock.weekday(save)
-- Mom's wheel moving the day is a change; setWeekday reports it
Clock.setWeekday(save, (Clock.weekday(save) + 3) % Clock.DAYS)
end)
T.eq(#seen, 1, "clock.day_changed does not fire on the first read")
T.eq(seen[1].reason, "set", "re-anchoring the day reports reason 'set'")
T.eq(seen[1].day, Clock.weekday(save), "clock.day_changed carries the new day")
T.check(seen[1].previous ~= seen[1].day,
"clock.day_changed carries a different previous day")
local quiet = capture("clock.day_changed", function()
Clock.weekday(save)
Clock.weekday(save)
Clock.weekday(save)
end)
T.eq(#quiet, 0, "a day that has not moved raises nothing")
end
-- ------- pokerus.infected
do
-- .TrySpreadPokerus: slot 1 already carries the virus, slot 2 is clean, and
-- the rolls below pass the spread gate and walk forward.
local party = {
{ species = "SEEDMON", pokerus = 0x11 },
{ species = "SEEDMON", pokerus = 0 },
}
local rolls = { 0, 255 }
local index = 0
local function random()
index = index + 1
return rolls[index] or 0
end
local seen = capture("pokerus.infected", function()
Pokerus.give(party, { random = random })
end)
T.eq(#seen, 1, "pokerus.infected fires once per newly infected slot")
T.eq(seen[1].slot, 2, "pokerus.infected carries the party slot")
T.eq(seen[1].mon, party[2], "pokerus.infected carries the mon")
T.eq(seen[1].source, "spread", "a spread reports source 'spread'")
T.eq(seen[1].strain, Pokerus.strain(party[2]),
"pokerus.infected carries the strain nybble")
T.eq(seen[1].days, Pokerus.days(party[2]),
"pokerus.infected carries the day counter")
T.check(Pokerus.isInfected(party[2]), "and the byte really was written")
end
-- ------- roamer.moved and roamer.encountered
do
local save = {}
Roamers.init(save)
local seen = capture("roamer.moved", function()
Roamers.jumpAll(save, "ROUTE_29", function(n) return n - 1 end)
end)
T.check(#seen > 0, "roamer.moved fires when JumpRoamMons scatters the beasts")
for _, payload in ipairs(seen) do
T.eq(payload.reason, "jump", "a teleport reports reason 'jump'")
T.check(payload.from ~= payload.to,
"roamer.moved only reports a beast that changed route")
T.eq(payload.slot.map, payload.to, "roamer.moved's `to` is where it stands")
end
-- CheckEncounterRoamMon: a byte under 100 whose low two bits pick slot 1
local beast = Roamers.slot(save, 1)
beast.map = "ROUTE_42"
local met = capture("roamer.encountered", function()
local hit = Roamers.checkEncounter(save, "ROUTE_42", false,
function() return 1 end)
T.check(hit ~= nil, "the roll met the roamer")
end)
T.eq(#met, 1, "roamer.encountered fires once per meeting")
T.eq(met[1].index, 1, "roamer.encountered carries the roamer slot")
T.eq(met[1].species, beast.species, "roamer.encountered carries the species")
T.eq(met[1].mapId, "ROUTE_42", "roamer.encountered carries the map")
end
-- ------- apricorn.converted
do
local save = { inventory = { RED_APRICORN = 1 }, events = {}, engineFlags = {} }
T.check(Apricorns.give(save, "RED_APRICORN"), "Kurt takes the apricorn")
-- .GiveLevelBall only fires once the daily flag has rolled over
save.engineFlags[Apricorns.ENGINE_KURT_MAKING_BALLS] = false
local seen = capture("apricorn.converted", function()
local ball = Apricorns.collect(save)
T.check(ball ~= nil, "the ball is ready to collect")
end)
T.eq(#seen, 1, "apricorn.converted fires once per ball handed over")
T.eq(seen[1].apricorn, "RED_APRICORN",
"apricorn.converted carries the apricorn that went in")
T.eq(seen[1].ball, Apricorns.ballFor("RED_APRICORN"),
"apricorn.converted carries the ball that came out")
T.check(seen[1].event ~= nil,
"apricorn.converted names the EVENT_GAVE_KURT_* flag it cleared")
end
-- ------- bug_contest.scored
do
local save = {}
local state = BugContest.state(save)
state.caught = { species = "SEEDMON", hp = 20, maxHp = 20,
stats = { attack = 10, defense = 10, speed = 10,
specialAttack = 10, specialDefense = 10 },
dvs = { attack = 2, defense = 2, speed = 2, special = 2 } }
local seen = capture("bug_contest.scored", function()
BugContest.runJudging(save, function() return 0 end)
end)
T.eq(#seen, 1, "bug_contest.scored fires once per judging")
T.eq(seen[1].mon, state.caught, "bug_contest.scored carries the player's mon")
T.eq(seen[1].score, BugContest.score(state.caught),
"bug_contest.scored carries the score DetermineContestWinners used")
T.eq(seen[1].place, state.place, "bug_contest.scored carries the placing")
T.check(seen[1].results ~= nil and seen[1].results.first ~= nil,
"bug_contest.scored carries the podium")
end
-- ------- unown.unlocked
do
local save = {}
local seen = capture("unown.unlocked", function()
Unown.updateDex(save, 1)
-- the same letter a second time is UpdateUnownDex's early return
Unown.updateDex(save, 1)
Unown.updateDex(save, 2)
end)
T.eq(#seen, 2, "unown.unlocked fires once per NEW form, not per catch")
T.eq(seen[1].letter, 1, "unown.unlocked carries the letter number")
T.eq(seen[1].name, "A", "unown.unlocked carries the letter name")
T.eq(seen[1].word, Unown.word(1), "unown.unlocked carries the form's word")
T.eq(seen[2].count, 2, "unown.unlocked carries the running count")
end
-- ------- mail.written and mail.read
do
local save = { player = { name = "GOLD", id = 7 },
party = { { species = "SEEDMON" } } }
local written = capture("mail.written", function()
Mail.compose(save, 1, "HI THERE", save.party[1], "LOVELY_MAIL")
end)
T.eq(#written, 1, "mail.written fires when the compose screen closes")
T.eq(written[1].slot, 1, "mail.written carries the party slot")
T.eq(written[1].source, "compose", "the compose screen reports 'compose'")
T.eq(written[1].author, "GOLD", "mail.written carries the author")
T.eq(written[1].message, "HI THERE", "mail.written carries the message")
T.eq(written[1].mon, save.party[1], "mail.written carries the mon it rides")
local given = capture("mail.written", function()
Mail.give(save, "LOVELY_MAIL", "FROM A FRIEND")
end)
T.eq(#given, 1, "GivePokeMail raises mail.written too")
T.eq(given[1].source, "script", "a scripted letter reports 'script'")
local entry = Mail.get(save, 1)
local read = capture("mail.read", function()
-- the reader redraws the page every frame; that is one opened letter
Mail.lines(entry)
Mail.lines(entry)
Mail.lines(entry)
end)
T.eq(#read, 1, "mail.read is one event per opened letter, not per frame")
T.eq(read[1].entry, entry, "mail.read carries the struct being read")
T.eq(read[1].message, entry.message, "mail.read carries the message")
T.check(read[1].top ~= nil and read[1].bottom ~= nil,
"mail.read carries the two rows MailGFX_PlaceMessage draws")
local reopened = capture("mail.read", function()
-- picking the letter again out of the record re-arms the latch
local again = Mail.get(save, 1)
Mail.lines(again)
Mail.lines(again)
end)
T.eq(#reopened, 1, "opening the same letter a second time is a second event")
end
-- ------- radio.channel
--
-- src/ui/gen2/MapRadio.lua is a LOVE state and its constructor reaches through
-- the Pokegear, so the seam is asserted here through the bus rather than by
-- building a screen: what this pins is that the name is on the wire and that a
-- listener sees the four fields the call site documents.
do
local seen = capture("radio.channel", function()
Runtime.emit("radio.channel", { station = "OAKS_POKEMON_TALK", channel = 1,
name = "OAK'S #MON TALK", source = "map_radio" })
end)
T.eq(#seen, 1, "radio.channel reaches a listener")
T.eq(seen[1].source, "map_radio", "radio.channel names the wall radio")
T.eq(seen[1].station, "OAKS_POKEMON_TALK", "radio.channel carries the station")
end
-- ------- shiny.roll and gender.roll
do
local shinyDvs = { attack = 2, defense = 10, speed = 10, special = 10 }
local plainDvs = { attack = 0, defense = 0, speed = 0, special = 0 }
T.eq(Mon.isShiny(shinyDvs), true, "the vanilla shiny pattern still reads true")
T.eq(Mon.isShiny(plainDvs), false, "and a plain DV set still reads false")
local ctxSeen
withHook("shiny.roll", function(nextFn, ctx)
ctxSeen = ctx
nextFn()
return true
end, function()
local mon = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs })
T.eq(mon.shiny, true, "shiny.roll can force a shiny")
end)
T.eq(ctxSeen.species, "SEEDMON", "shiny.roll's ctx carries the species")
T.eq(ctxSeen.level, 5, "shiny.roll's ctx carries the level")
T.check(ctxSeen.dvs ~= nil, "shiny.roll's ctx carries the DVs")
-- a forced-shiny battle overrides the roll rather than hooking it
withHook("shiny.roll", function() return false end, function()
local forced = Mon.new(DATA, "SEEDMON", 5,
{ dvs = plainDvs, shiny = true })
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
end)
local genderCtx
withHook("gender.roll", function(nextFn, ctx)
genderCtx = ctx
nextFn()
return "female"
end, function()
local mon = Mon.new(DATA, "SEEDMON", 5,
{ dvs = { attack = 15, defense = 0, speed = 0, special = 0 } })
T.eq(mon.gender, "female", "gender.roll can replace the answer")
end)
T.eq(genderCtx.ratio, DATA.pokemon.SEEDMON.genderRatio,
"gender.roll's ctx carries the species' ratio byte")
T.eq(genderCtx.species, "SEEDMON", "gender.roll's ctx carries the species")
-- anything that is not one of the three genders falls back to vanilla
withHook("gender.roll", function() return "enby" end, function()
T.eq(Mon.gender(DATA.pokemon.SEEDMON,
{ attack = 15, defense = 0, speed = 0, special = 0 }), "male",
"an unknown gender.roll answer falls back to the DV read")
end)
end
-- ------- held_item.trigger
--
-- Driven through Battle:heldEffect directly: it is the one function every
-- held-item site on Gold reads its (effect, parameter) pair out of, which is
-- the property that makes one hook cover all eight triggers.
do
local Battle = require("src.battle.gen2.Battle")
local battle = setmetatable({
data = { items = { KINGS_ROCK = { id = "KINGS_ROCK", name = "KING'S ROCK",
heldEffect = "HELD_FLINCH",
heldParameter = 30 } } },
}, Battle)
local mon = { species = "SEEDMON", item = "KINGS_ROCK" }
local effect, parameter = battle:heldEffect(mon, "flinch")
T.eq(effect, "HELD_FLINCH", "the vanilla held effect comes off the item")
T.eq(parameter, 30, "and so does its parameter")
local ctxSeen
withHook("held_item.trigger", function(nextFn, ctx)
ctxSeen = ctx
return nextFn()
end, function()
local e, p = battle:heldEffect(mon, "flinch")
T.eq(e, "HELD_FLINCH", "held_item.trigger's vanilla answers the item")
T.eq(p, 30, "held_item.trigger's vanilla answers the parameter")
end)
T.eq(ctxSeen.trigger, "flinch", "held_item.trigger names which site called")
T.eq(ctxSeen.mon, mon, "held_item.trigger carries the holder")
T.eq(ctxSeen.item, "KINGS_ROCK", "held_item.trigger carries the item id")
T.eq(ctxSeen.effect, "HELD_FLINCH", "held_item.trigger carries the effect")
T.eq(ctxSeen.parameter, 30, "held_item.trigger carries the parameter")
T.eq(ctxSeen.battle, battle, "held_item.trigger carries the battle")
-- suppression: nil is "this item does nothing at this trigger"
withHook("held_item.trigger", function() return nil end, function()
T.eq(battle:heldEffect(mon, "flinch"), nil,
"held_item.trigger can switch an item off")
end)
-- substitution: another HELD_* name, keeping the item's own parameter
withHook("held_item.trigger", function() return "HELD_QUICK_CLAW" end,
function()
local e, p = battle:heldEffect(mon, "priority")
T.eq(e, "HELD_QUICK_CLAW", "held_item.trigger can substitute an effect")
T.eq(p, 30, "and the item's own parameter survives the substitution")
end)
-- the residual arm reads through the same seam
local leftovers = { species = "SEEDMON", item = "LEFTOVERS", hp = 10,
maxHp = 20, stats = { hp = 20 } }
battle.data.items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS",
heldEffect = "HELD_LEFTOVERS" }
local residual
withHook("held_item.trigger", function(nextFn, ctx)
residual = ctx.trigger
return nextFn()
end, function()
battle:heldEffect(leftovers, "residual")
end)
T.eq(residual, "residual",
"the end-of-turn arm reaches held_item.trigger as 'residual'")
end
-- ------- intro.boot.*: the GS boot cinema
--
-- Red boots into IntroMovie and has no copyright card, no GAME FREAK splash
-- and no attract movie, so these four cards are the rare case where a NEW name
-- is the honest one -- there is no Gen 1 moment to share with. (The Oak
-- speech next door is the opposite case and reuses intro.oak_speech.* verbatim;
-- gate_gen2_mod_api.lua holds that half.) One name per card, raised the frame
-- the card comes up, plus the one card end that carries a fact nothing
-- downstream does: whether the movie was watched or skipped.
--
-- The screens take their data by argument and draw nothing here, so the whole
-- chain runs ROM-free.
do
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
local TitleState = require("src.ui.gen2.TitleState")
local game = { data = {}, save = { player = {} } }
local seen = capture("intro.boot.copyright", function()
CopyrightSplash.new(game, {}):enter()
end)
T.eq(#seen, 1, "the copyright card raises intro.boot.copyright once")
T.check(seen[1].screen ~= nil and seen[1].game == game,
"intro.boot.copyright carries { screen, game }")
seen = capture("intro.boot.gamefreak", function()
GameFreakPresents.new(game, {}):enter()
end)
T.eq(#seen, 1, "the GAME FREAK splash raises intro.boot.gamefreak once")
T.check(seen[1].screen ~= nil and seen[1].game == game,
"intro.boot.gamefreak carries { screen, game }")
local movie
seen = capture("intro.boot.movie", function()
movie = GoldSilverIntro.new(game, {})
movie:enter()
end)
T.eq(#seen, 1, "the attract movie raises intro.boot.movie once")
T.check(seen[1].screen == movie and seen[1].game == game,
"intro.boot.movie carries { screen, game }")
-- GoldSilverIntro.PlayFrame's PAD_BUTTONS exit.
seen = capture("intro.boot.movie_ended", function() movie:skip() end)
T.eq(#seen, 1, "a skipped movie raises intro.boot.movie_ended once")
T.eq(seen[1].skipped, true, "and reports skipped = true")
T.check(type(seen[1].frames) == "number",
"intro.boot.movie_ended carries the frame count it reached")
-- IntroScene17's `ld c, 64` tail, i.e. the movie run to its end.
seen = capture("intro.boot.movie_ended", function()
local watched = GoldSilverIntro.new(game, {})
watched:enter()
watched:finish()
end)
T.eq(seen[1].skipped, false, "a movie watched through reports skipped = false")
seen = capture("intro.boot.title", function()
TitleState.new(game, {}):enter()
end)
T.eq(#seen, 1, "the title screen raises intro.boot.title once")
T.check(seen[1].screen ~= nil and seen[1].game == game,
"intro.boot.title carries { screen, game }")
end
-- ------- the mod-free state is restored
for _, name in ipairs({ "intro.boot.copyright", "intro.boot.gamefreak",
"intro.boot.movie", "intro.boot.movie_ended",
"intro.boot.title",
"happiness.changed", "breeding.egg_created",
"egg.hatched", "phone.call_received",
"clock.day_changed", "pokerus.infected",
"roamer.moved", "roamer.encountered",
"apricorn.converted", "bug_contest.scored",
"unown.unlocked", "radio.channel", "mail.written",
"mail.read" }) do
T.eq(Runtime.wants(name), false,
"every case unsubscribed: " .. name)
end
-- Hooks:wrap's remover empties the chain but leaves the (empty) table, the
-- same residue gate_events.lua documents for the event bus, so the check is on
-- the chain's contents rather than on wantsHook.
for _, name in ipairs({ "held_item.trigger", "breeding.compatibility",
"phone.contact_list", "shiny.roll", "gender.roll" }) do
T.eq(#(hooks.chains[name] or {}), 0, "every hook case unwrapped: " .. name)
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.errors = nil
T.finish("gen2_new_seams")
+207 -4
View File
@@ -104,17 +104,26 @@ end
-- ------- warn: unsatisfied game_version range against Version.engine
do
-- a range the -dev engine cannot satisfy (needs a released >=1.0.0)
-- Stamped like a shipped build, because the 0.0.0-dev placeholder is not a
-- compatibility statement and both LauncherMods and Loader.devEngine skip
-- the range check on it. Unstamped, this row is "ok" on purpose.
local was = Version.engine
Version.engine = "1.4.0"
local manifests = {
mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua",
game_version = ">=1.0.0" }),
game_version = ">=9.9.9" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.future.status, "warn", "engine outside the game_version range warns")
check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil,
check(m.future.statusDetail:find(">=9.9.9", 1, true) ~= nil,
"version warn detail quotes the required range")
check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil,
check(m.future.statusDetail:find("1.4.0", 1, true) ~= nil,
"version warn detail quotes the engine version")
Version.engine = was
-- and the dev placeholder agrees with the loader instead of warning
local dev = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(dev.future.status, "ok", "a dev checkout does not warn where the loader loads")
end
-- ------- warn: hard dependency missing, disabled, or wrong version
@@ -166,6 +175,200 @@ do
"conflict is reported ahead of a version warn on the same mod")
end
-- ------- which game a row is answered for (src/mods/ModTargets.lua)
do
local manifests = {
mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }),
mf({ id = "two", name = "Two", version = "1.0.0", entry = "m.lua",
games = { "gen2" } }),
mf({ id = "both", name = "Both", version = "1.0.0", entry = "m.lua",
games = { "all" } }),
}
-- no game named: the pre-per-game view, where every row is just ready
local all = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(all.one.targets, "GEN 1", "the chip says which games the mod is for")
eq(all.two.targets, "GEN 2", "for each of them")
eq(all.both.targets, "GEN 1+2", "including both")
eq(all.one.targetsHere, nil, "with no game to answer for, nothing is claimed")
eq(all.two.status, "ok", "and no row is judged against a game")
local onGold = byId(LauncherMods.deriveList(manifests, { mods = {} }, "gold"))
eq(onGold.one.status, "other_game", "a Gen 1 mod is not for Gold")
eq(onGold.one.statusDetail, "For Gen 1, not Gold", "and says so in one line")
eq(onGold.one.targetsHere, false, "the row carries the verdict too")
eq(onGold.two.status, "ok", "a Gen 2 mod is ready there")
eq(onGold.both.targetsHere, true, "and so is one that claims both")
local onRed = byId(LauncherMods.deriveList(manifests, { mods = {} }, "red"))
eq(onRed.two.status, "other_game", "the same rule points the other way")
eq(onRed.one.status, "ok", "without touching the Gen 1 mod")
end
-- ------- the row is a verdict, not a decoration: the loader enforces it
--
-- "For Blue, not Red" has to be what the boot does, per VERSION and not only
-- per generation, or the panel is reporting a claim while the mod runs anyway.
-- Real loader, real gate (Loader:_gateGeneration), no love.
do
local Sdk = require("tests.modkit.sdk")
local GameVersion = require("src.core.GameVersion")
local function manifestFile(id, games)
return ("{\"id\":\"%s\",\"name\":\"%s\",\"version\":\"1.0.0\"," ..
"\"entry\":\"main.lua\",\"api\":2,\"games\":[\"%s\"]}"):format(id, id, games)
end
local FILES = {
["mods/blueonly/manifest.json"] = manifestFile("blueonly", "blue"),
["mods/blueonly/main.lua"] = "local mod = ...\n",
["mods/goldonly/manifest.json"] = manifestFile("goldonly", "gold"),
["mods/goldonly/main.lua"] = "local mod = ...\n",
["mods/anygame/manifest.json"] = manifestFile("anygame", "all"),
["mods/anygame/main.lua"] = "local mod = ...\n",
}
local paths = { "mods/blueonly", "mods/goldonly", "mods/anygame" }
local was = GameVersion.get()
GameVersion.set("red")
local run = Sdk.loadMods(paths, { fs = Sdk.memfs(FILES), generation = 1 })
local rows = byId(LauncherMods.deriveList({
mf({ id = "blueonly", name = "blueonly", version = "1.0.0",
entry = "main.lua", games = { "blue" } }),
mf({ id = "goldonly", name = "goldonly", version = "1.0.0",
entry = "main.lua", games = { "gold" } }),
mf({ id = "anygame", name = "anygame", version = "1.0.0",
entry = "main.lua", games = { "all" } }),
}, { mods = {} }, "red"))
for _, id in ipairs({ "blueonly", "goldonly", "anygame" }) do
local ran = run.loader.mods[id].state ~= "wrong_generation"
eq(ran, rows[id].targetsHere,
"the loader and the panel agree about " .. id .. " on Red")
end
eq(run.loader.mods.blueonly.state, "wrong_generation",
"a Blue-only mod does not run on Red")
eq(run.loader.mods.blueonly.skipReason, "For Blue, not Red",
"and the skip line is the launcher's own line")
eq(run.loader.mods.anygame.state, "loaded", "a mod for every game still runs")
run.release()
-- the override answers for ONE game. A version-blind flag forced a mod
-- past the gate on a game whose owner was never asked (SaveData.modForced).
local Serializer = require("src.core.SaveSerializer")
local function bootWith(modsGen2, generation)
local fs = Sdk.memfs(FILES)
fs.write("options.lua", Serializer.encode({ mods = {}, modsGen2 = modsGen2 }))
local r = Sdk.loadMods(paths, { fs = fs, generation = generation })
local state = r.loader.mods.blueonly.state
r.release()
return state
end
eq(bootWith({ blueonly = { red = true } }, 1), "loaded",
"an override for Red runs the Blue-only mod on Red")
eq(bootWith({ blueonly = { blue = true } }, 1), "wrong_generation",
"an override for another game does not answer for Red")
eq(bootWith({ blueonly = true }, 1), "wrong_generation",
"a pre-per-game flag keeps its old meaning: Gen 2 only, never Red")
GameVersion.set("gold")
eq(bootWith({ blueonly = true }, 2), "loaded",
"and on the Gen 2 game it always meant, it still forces")
eq(bootWith({}, 2), "wrong_generation", "with no override the gate holds")
if was then GameVersion.set(was) end
end
do
-- the player's override is the one thing that outranks the author's claim,
-- and it must read as the untested thing it is (Loader:_gateGeneration)
local manifests = {
mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests,
{ mods = {}, modsGen2 = { one = true } }, "gold"))
eq(m.one.status, "warn", "a forced mod is a warning, not a wrong game")
check(m.one.statusDetail:find("Gold", 1, true) ~= nil,
"and the line names the game it was forced onto")
eq(m.one.targetsHere, true, "it will run there")
end
-- ------- enable flags: the panel reads exactly what the switch writes
--
-- One scope for both halves (SaveData.modScope). While per-game flags are a
-- preview the shared flag is the whole answer, so a modsByVersion overlay --
-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can
-- never leave the switch showing an answer no writer can reach.
local SaveData = require("src.core.SaveData")
local function flip(options, id, enabled, version)
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
end
do
local manifests = {
mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua",
games = { "all" } }),
}
local planted = { mods = { one = true },
modsByVersion = { gold = { one = false } } }
local expected = SaveData.PER_VERSION_MODS and false or true
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled,
expected, "the overlay is read exactly when a write can reach it")
-- the round trip, the thing the dead switch failed: flip it, re-derive
local options = { mods = {} }
for _, version in ipairs({ "red", "gold" }) do
flip(options, "one", false, version)
eq(byId(LauncherMods.deriveList(manifests, options, version)).one.enabled,
false, "switching off reads back off on " .. version)
flip(options, "one", true, version)
eq(byId(LauncherMods.deriveList(manifests, options, version)).one.enabled,
true, "and switching on reads back on on " .. version)
end
-- the same round trip through the planted overlay: no write is ignored
flip(planted, "one", false, "gold")
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled,
false, "a planted overlay cannot outrank the player's own write")
flip(planted, "one", true, "gold")
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled,
true, "in either direction")
-- and what the loader will do agrees with the row, per game
eq(SaveData.modEnabled(planted, "one", SaveData.modScope("gold")), true,
"the loader resolves the flag under the same scope the panel read")
end
-- ------- a dependency that does not run here is a dependency problem
--
-- The loader's target skip is contagious (Loader:_enforceDependencies), so a
-- mod that runs on every game still does not run on Gold when the mod it
-- needs is Gen 1 only.
do
local manifests = {
mf({ id = "base", name = "Base", version = "1.0.0", entry = "m.lua",
games = { "gen1" } }),
mf({ id = "user", name = "User", version = "1.0.0", entry = "m.lua",
games = { "all" }, dependencies = { "base" } }),
}
local onGold = byId(LauncherMods.deriveList(manifests, { mods = {} }, "gold"))
eq(onGold.user.status, "warn",
"a dependency that cannot run here is a warning, not Ready")
check(onGold.user.statusDetail:find("base", 1, true) ~= nil
and onGold.user.statusDetail:find("Gold", 1, true) ~= nil,
"and the line names the dependency and the game")
eq(byId(LauncherMods.deriveList(manifests, { mods = {} }, "red")).user.status,
"ok", "while the same pair is Ready where both run")
-- the player's override on the DEPENDENCY clears it: same scope the loader
-- resolves the override under (SaveData.modForced)
local forced = { mods = {}, modsGen2 = { base = { gold = true } } }
eq(byId(LauncherMods.deriveList(manifests, forced, "gold")).user.status, "ok",
"forcing the dependency onto Gold clears the dependent's warning")
eq(byId(LauncherMods.deriveList(manifests,
{ mods = {}, modsGen2 = { base = { red = true } } }, "gold")).user.status,
"warn", "an override for another game does not answer for this one")
end
-- ------- locateRoot: manifest at the archive root
do
+208
View File
@@ -0,0 +1,208 @@
-- Which games a mod is for: the manifest `games` key, the legacy gen2compat
-- reading of it, the per-game enable overlay both mod surfaces resolve
-- through, and the profile that carries a per-game set between installs.
-- luajit tests/engine/mod_targets_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GameVersion = require("src.core.GameVersion")
local Manifest = require("src.mods.Manifest")
local ModProfile = require("src.mods.ModProfile")
local ModTargets = require("src.mods.ModTargets")
local SaveData = require("src.core.SaveData")
local function mf(raw)
raw.id = raw.id or "m"
raw.name = raw.name or "M"
raw.version = raw.version or "1.0.0"
raw.entry = raw.entry or "main.lua"
return Manifest.validate(raw)
end
local function list(m)
return table.concat(ModTargets.versions(m), ",")
end
-- ------- tokens expand off GameVersion, never a literal list
do
eq(table.concat(ModTargets.expand("red"), ","), "red",
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
do
local versions, unknown = ModTargets.normalize({ "gold", "red", "red" })
eq(table.concat(versions, ","), "red,gold",
"normalize dedupes and sorts into GameVersion.ORDER")
eq(#unknown, 0, "known tokens leave nothing unreported")
local _, bad = ModTargets.normalize({ "crystal", "gen1" })
eq(#bad, 1, "an unknown token comes back for the caller to report")
eq(bad[1], "crystal", "by name")
end
-- ------- the legacy reading: gen2compat only ever ADDS Gen 2
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
end
-- ------- games declares it directly, and the loader's gate reads the derived
-- gen2compat, so no Loader change is needed to honour the new key
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold",
"an old gen2compat beside a new games list still adds its game")
end
do
-- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like
-- every other manifest vocabulary (Manifest.violation)
local lenient = mf({ games = { "crystal", "red" } })
eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest")
check(not pcall(mf, { api = 2, games = { "crystal" } }),
"api 2 refuses a game it does not have")
check(not pcall(mf, { games = "gen1" }),
"games must be an array, not a bare string")
eq(list(mf({ games = {} })), "red,blue,yellow",
"an empty games list falls back rather than orphaning the mod")
end
-- ------- supports / runsHere: the claim, then what actually happens
do
local gen1 = mf({ id = "one" })
local gen2 = mf({ id = "two", games = { "gen2" } })
check(ModTargets.supports(gen1, "red"), "a Gen 1 mod supports Red")
check(not ModTargets.supports(gen1, "gold"), "and not Gold")
check(ModTargets.supports(gen2, "gold"), "a Gen 2 mod supports Gold")
check(not ModTargets.supports(gen2, "red"), "and not Red")
check(ModTargets.supports(gen1, nil, 1), "a generation can be asked directly")
check(not ModTargets.supports(gen1, nil, 2), "and answers the same way")
check(not ModTargets.runsHere(gen1, "gold"), "no claim, no run")
check(ModTargets.runsHere(gen1, "gold", nil, true),
"the player's override is what forces one anyway")
end
-- ------- one label, both surfaces
do
eq(ModTargets.label(mf({})), "Gen 1", "whole generations read as generations")
eq(ModTargets.label(mf({ games = { "all" } })), "Gen 1+2", "both of them")
eq(ModTargets.label(mf({ games = { "gen2" } })), "Gen 2", "or just the one")
eq(ModTargets.label(mf({ games = { "red", "gold" } })), "Red/Gold",
"part of a generation reads as the games themselves")
eq(ModTargets.chip(mf({})), "GEN 1", "the chip is the same label, uppercased")
eq(ModTargets.detail(mf({}), "gold"), "For Gen 1, not Gold",
"and the launcher line names both sides")
end
-- ------- per-game enable overlay: absent means the shared flag, which is
-- what every options.lua written before this key holds
do
local opts = { mods = { a = false, b = true } }
eq(SaveData.modEnabled(opts, "a", "gold"), false,
"no overlay entry falls through to the shared flag")
eq(SaveData.modEnabled(opts, "b"), true, "with or without a game")
eq(SaveData.modEnabled(opts, "ghost", "gold"), nil,
"an unanswered mod is nil, so the caller owns the default")
opts.modsByVersion = { gold = { a = true } }
eq(SaveData.modEnabled(opts, "a", "gold"), true, "the game's own answer wins")
eq(SaveData.modEnabled(opts, "a", "red"), false, "for that game only")
eq(SaveData.modEnabled(opts, "a"), false, "and the shared view is untouched")
end
do
local opts = { mods = {} }
SaveData.setModEnabled(opts, "a", false, "gold")
eq(opts.modsByVersion.gold.a, false, "a per-game write lands in that game")
eq(opts.mods.a, nil, "and never in the shared flag")
SaveData.setModEnabled(opts, "a", false)
eq(opts.mods.a, false, "a shared write lands in the shared flag")
SaveData.setModEnabled(opts, "a", false, "gold")
eq(opts.modsByVersion.gold.a, nil,
"a per-game answer that agrees with the shared one is dropped, not stored")
eq(SaveData.modEnabled(opts, "a", "gold"), false, "and still resolves the same")
local fresh = { mods = {} }
SaveData.setModEnabled(fresh, "b", true, "gold")
eq(fresh.modsByVersion.gold.b, nil,
"no shared flag reads as enabled, so agreeing with it stores nothing")
end
do
-- the write scope is gated on the loader honouring it, so no surface can
-- promise a per-game set the boot would ignore
eq(SaveData.modScope("gold"), SaveData.PER_VERSION_MODS and "gold" or nil,
"modScope follows the PER_VERSION_MODS switch")
end
-- ------- a profile carries the per-game half of a setup
do
local available = {
{ id = "a", enabled = true }, { id = "b", enabled = false },
}
local byVersion = { gold = { a = false }, red = {} }
local p = ModProfile.capture(available, {}, byVersion)
eq(p.enabledByVersion.gold.a, false, "capture takes the per-game answers")
eq(p.enabledByVersion.red, nil, "an empty game is not carried")
p.name = "PROF"
local back = ModProfile.decode(ModProfile.encode(p))
eq(back.enabledByVersion.gold.a, false, "and they survive an export")
local opts = { mods = {} }
ModProfile.restoreVersions(back, opts)
eq(opts.modsByVersion.gold.a, false, "applying a profile restores them")
check(ModProfile.matchesVersions(back, opts),
"a restored setup still reads as that profile")
opts.modsByVersion.gold.a = true
check(not ModProfile.matchesVersions(back, opts),
"and drifts to ad-hoc as soon as one game differs")
local untouched = { modsByVersion = { gold = { z = true } } }
ModProfile.restoreVersions({ enabledByVersion = {} }, untouched)
eq(untouched.modsByVersion.gold.z, true,
"a profile that names no game blanks none")
end
do
-- a shared .g1rmodlist is untrusted input; the manager indexes it straight
-- into options.modsByVersion
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
T.finish("mod_targets")
+76
View File
@@ -0,0 +1,76 @@
-- Commands.pushBattle (src/script/Commands.lua): the shared entry point
-- for start_battle, old_man_demo, and the PALLET_TOWN Pikachu catch
-- (data/scripts/story2.lua). Every one of those call sites used to carry
-- its own copy of "if ctx.overworld.pushBattle then ... else
-- ctx.game.stack:push(battle) end"; this locks the dedup in so a future
-- edit to one call site can't silently drop the transition wipe for the
-- others.
-- luajit tests/engine/push_battle_transition.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local Commands = require("src.script.Commands")
local Logger = require("src.core.Logger")
local battle = { id = "the battle" }
-- Runs fn() with Logger.warn spied instead of hitting the real
-- print()/Logger.history ring buffer, and returns the last formatted
-- warning (or nil if none). pcall-wrapped so an error inside fn() still
-- restores Logger.warn before propagating -- a leaked spy would swallow
-- every later warning in the same process silently.
local function withWarnSpy(fn)
local warned
local origWarn = Logger.warn
Logger.warn = function(fmt, ...) warned = string.format(fmt, ...) end
local ok, err = pcall(fn)
Logger.warn = origWarn
if not ok then error(err, 0) end
return warned
end
-- ctx.overworld has a real pushBattle: it must be used, not a bare stack
-- push, so the flash/wipe transition and the battle-theme start survive.
do
local pushed
local ow = { pushBattle = function(self, b) pushed = b end }
local stackPushed
local ctx = { overworld = ow, game = { stack = {
push = function(_, b) stackPushed = b end } } }
Commands.pushBattle(ctx, battle)
eq(pushed, battle, "ctx.overworld:pushBattle is called with the battle")
check(stackPushed == nil, "the bare stack push is not also taken")
end
-- ctx.overworld without a pushBattle method (a partial test double, per
-- BattleState:finish's "no live children" contract) falls back to a
-- bare stack push and logs, rather than silently skipping the transition.
do
local stackPushed
local ctx = { overworld = {}, game = { stack = {
push = function(_, b) stackPushed = b end } } }
local warned = withWarnSpy(function() Commands.pushBattle(ctx, battle) end)
eq(stackPushed, battle, "falls back to ctx.game.stack:push")
check(warned ~= nil, "the fallback logs a warning")
check(warned and warned:find("pushBattle") ~= nil,
"the warning names pushBattle")
end
-- ctx.overworld absent entirely (headless script tests that never set
-- one up): same fallback, no crash on the ctx.overworld.pushBattle read
-- -- and still spied, since this also takes the warning path.
do
local stackPushed
local ctx = { game = { stack = {
push = function(_, b) stackPushed = b end } } }
local warned = withWarnSpy(function() Commands.pushBattle(ctx, battle) end)
eq(stackPushed, battle, "falls back to ctx.game.stack:push with no overworld")
check(warned ~= nil, "this fallback also logs a warning")
end
T.finish("push_battle_transition")
+15
View File
@@ -69,6 +69,21 @@ do
local n2, m2 = SaveData.slotSummary(nil)
T.eq(n2, nil, "slotSummary of an empty slot has no name")
T.eq(m2, nil, "slotSummary of an empty slot has no meta")
-- A Gen 2 (Gold) save stores playTime as a { hours, minutes, seconds,
-- frames } table, not a seconds count. The launcher lists EVERY version's
-- slots, so a math.floor on that table crashed the whole launcher the moment
-- a Gold save existed -- and dropped its CONTINUE row. slotSummary reads
-- both shapes now.
local gName, gMeta = SaveData.slotSummary({
player = { name = "GOLD" },
playTime = { hours = 3, minutes = 35, seconds = 40, frames = 45 },
pokedex = { owned = { CYNDAQUIL = true, PIDGEY = true } },
})
T.eq(gName, "GOLD", "slotSummary reads a Gen 2 save's name")
T.eq(gMeta.dexCount, 2, "and its dex count")
T.eq(gMeta.timeText, "3:35",
"and formats the Gen 2 { hours, minutes, seconds } playTime without crashing")
end
-- ---------------------------------------------- legacy migration happy path
@@ -0,0 +1,178 @@
-- Scripted story battles checkpoint a semantic row-list continuation. The
-- suspended Lua coroutine is deliberately never serialized.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local oldGetRandomState = love.math.getRandomState
local oldSetRandomState = love.math.setRandomState
local checkpointRng = "scripted-battle-rng"
love.math.getRandomState = function() return checkpointRng end
love.math.setRandomState = function(state) checkpointRng = state end
local T = require("tests.harness").suite("scripted battle checkpoints")
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Fixtures = require("tests.modkit").fixtures
local GameMethods = require("src.core.Game")
local OverworldState = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame()
local save = SaveData.newGame()
save.meta.playthroughId = "script-battle-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
SaveData.validate(save, Data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local game
local ow = setmetatable({
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}, { __index = OverworldState })
function ow:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing, target.player.surfing = self.player.facing, false
end
function ow:pushBattle(battle) game.stack:push(battle) end
function ow:afterBattle(result, battle)
self.after = { result = result, battle = battle }
end
game = setmetatable({ data = Data, save = save, stack = stack, overworld = ow },
{ __index = GameMethods })
function game:restoreCheckpointSave(loaded)
self.save = loaded
ow.map = { id = loaded.player.map }
ow.player = {
cellX = loaded.player.x, cellY = loaded.player.y,
facing = loaded.player.facing, surfing = loaded.player.surfing and true or false,
}
ow.parallelRunners, ow.pendingScripts, ow.parallelQueue, ow.scriptMoves = {}, {}, {}, {}
ow.runner = ScriptRunner.new(self, ow)
self.stack.states = { ow }
end
stack.states[1] = ow
ow.runner = ScriptRunner.new(game, ow)
return game, ow
end
local rows = {
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
{ "set_flag", "EVENT_STORY_CONTINUED" },
}
local game, ow = makeGame()
ow.runner:run(rows)
local battle = game.stack:top()
T.check(getmetatable(battle) == BattleState,
"script command starts the fixture trainer battle")
T.check(type(battle.checkpointOrigin) == "table"
and battle.checkpointOrigin.kind == "script_battle",
"script battle owns a semantic checkpoint continuation")
battle.phase, battle.queue = "menu", {}
battle.afterQueue, battle.introSlide = nil, nil
battle.player.shownHP, battle.player.shownStatus =
battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status
local scriptedCapability = Checkpoint.inspect(game)
T.same(scriptedCapability, {
canCapture = true, canRestore = true, kind = "battle",
}, "settled scripted trainer decision is checkpoint-safe: "
.. tostring(scriptedCapability.reason))
local origin = battle.checkpointOrigin
T.check(type(origin.script) == "table" and origin.pc == 1,
"continuation records detached rows and the command program counter")
T.eq(origin.resumeCoroutine, nil,
"continuation never exposes a coroutine or Lua execution stack")
local snapshot, captureCode, captureMessage = Checkpoint.capture(game)
T.check(snapshot and snapshot.kind == "battle",
"scripted battle captures through the generic checkpoint API: "
.. tostring(captureCode or captureMessage))
if snapshot then
game.save.money = 1
local restored, restoreCode, restoreMessage = Checkpoint.restore(game, snapshot)
T.check(restored == true,
"scripted battle reconstructs through the generic checkpoint API: "
.. tostring(restoreCode or restoreMessage))
local rebuilt = game.stack:top()
T.check(rebuilt ~= battle and getmetatable(rebuilt) == BattleState,
"scripted restore creates a fresh battle controller")
T.same(Checkpoint.capture(game), snapshot,
"scripted battle capture/restore/capture is a differential roundtrip")
end
-- Rebind the continuation on a freshly reconstructed overworld. Completing
-- the battle must replay the current command as an already-completed battle,
-- then execute the remaining story rows exactly once.
local restoredGame, restoredOw = makeGame()
local restoredBattle = BattleState.newTrainer(restoredGame,
"OPP_FIX_YOUNGSTER", 1)
restoredBattle.checkpointOrigin = origin
T.check(restoredOw:restoreBattleContinuation(restoredBattle, origin) == true,
"script continuation reconstructs without the old runner")
restoredBattle.onFinish("win")
T.check(restoredGame.save.flags.EVENT_STORY_CONTINUED == true,
"restored battle resumes subsequent story progress")
T.same(restoredOw.after, { result = "win", battle = restoredBattle },
"restored script battle uses the canonical afterBattle path")
T.check(not restoredOw.runner:isRunning(),
"reconstructed continuation completes without a suspended runner")
-- Unsafe rows and opaque completion callbacks must stay fail-closed.
local unsafeGame, unsafeOw = makeGame()
local unsafeRows = {
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
}
unsafeRows.opaque = function() end
unsafeOw.runner:run(unsafeRows)
local unsafeBattle = unsafeGame.stack:top()
unsafeBattle.phase, unsafeBattle.queue = "menu", {}
T.eq(unsafeBattle.checkpointOrigin, nil,
"non-data-only script arguments do not create a continuation")
T.eq(Checkpoint.inspect(unsafeGame).reason, "battle_origin_unsupported",
"non-data-only scripted battle remains unavailable")
local callbackGame, callbackOw = makeGame()
callbackOw.runner:run(rows, { onDone = function() end })
local callbackBattle = callbackGame.stack:top()
callbackBattle.phase, callbackBattle.queue = "menu", {}
T.eq(callbackBattle.checkpointOrigin, nil,
"opaque script completion callbacks are not guessed")
T.eq(Checkpoint.inspect(callbackGame).reason, "battle_origin_unsupported",
"opaque scripted completion remains fail-closed")
local rivalGame, rivalOw = makeGame()
local rivalRows = {
{ "rival_battle", "OPP_FIX_YOUNGSTER", 1 },
{ "jump_if_false", "end" },
{ "set_flag", "EVENT_RIVAL_STORY_CONTINUED" },
}
rivalOw.runner:run(rivalRows)
local rivalBattle = rivalGame.stack:top()
local rivalOrigin = rivalBattle.checkpointOrigin
T.check(rivalOrigin and rivalOrigin.command == "rival_battle" and rivalOrigin.pc == 1,
"wrapper battle records the wrapper command rather than skipping its tail")
local rivalResumeGame, rivalResumeOw = makeGame()
local rivalRestored = BattleState.newTrainer(rivalResumeGame,
"OPP_FIX_YOUNGSTER", 1)
T.check(rivalResumeOw:restoreBattleContinuation(rivalRestored, rivalOrigin) == true,
"rival wrapper continuation reconstructs")
rivalRestored.onFinish("win")
T.check(rivalResumeGame.save.flags.EVENT_RIVAL_STORY_CONTINUED == true,
"rival wrapper and following branch execute exactly once after restore")
love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState
T.finish()

Some files were not shown because too many files have changed in this diff Show More