Add Pokemon Crystal as a sixth supported version

Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.

Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
  make_gold_manifest as a library, with three additive keyword seams. Gold and
  Silver still regenerate byte-identical, which is the standing requirement for
  touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
  Crystal renames the credits mons, splits the trainer card, Pokegear and
  pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
  badge block sits one higher) and unownCharmap (the main charmap parser stops
  at the first newcharmap so the two cannot contaminate each other).

Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
  0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
  seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
  steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
  Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
  intro and title via src/import/CrystalMovie.lua.

Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
  original bugs where the bug is not hardware dependent; Crystal gets the fixes
  Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
  Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
  of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
  including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
  and mon caught data.

Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
This commit is contained in:
bryanthaboi
2026-08-23 12:10:28 -04:00
parent e88f2ef060
commit ca4d3d283c
141 changed files with 43306 additions and 583 deletions
@@ -0,0 +1,173 @@
-- The Battle Tower lobby, driven in a real game.
--
-- POKEPORT_IDENTITY=lead-card POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower \
-- POKEPORT_DRIVER=tests/drivers/crystal_battle_tower_shots.lua love .
--
-- Three sections, all on maps/BattleTower1F.asm's own extracted script:
-- A an illegal party, so _CheckForBattleTowerRules prints its refusals
-- B a legal party through Menu_ChallengeExplanationCancel and the room menu
-- C the walk to the elevator the chosen room starts
--
-- Section B overrides the TryQuickSave stub for the length of the run:
-- src/script/gen2/Specials.lua:2516 answers 0, and BattleTower1F.asm:84-85
-- backs out of the whole challenge on that, so the desk cannot be walked
-- past without it.
local U = require("tests.drivers.util")
local BattleTower = require("src.core.gen2.BattleTower")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
local Vm = require("src.script.gen2.Vm")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
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
local function top() return game.stack:top() end
local function isMenu() return getmetatable(top()) == ScriptMenu end
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local function party(spec)
local list = {}
for _, row in ipairs(spec) do
local m = Mon.new(game.data, row[1], row[2])
m.item = row[3]
list[#list + 1] = m
end
game.save.party = list
end
local function enterLobby()
while top() do tap("b", 2) end
assert(world:setMap("BATTLE_TOWER_1F", 7, 7, "up"), "no BATTLE_TOWER_1F")
U.wait(40)
end
-- maps/BattleTower1F.asm:55-73: A through the welcome, NO to the
-- explanation offer, and the menu is the next command.
local function talkToMenu(limit)
for _ = 1, limit or 200 do
if isMenu() then return true end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
return false
end
say("--- A: an illegal party at the desk")
party({ { "TYPHLOSION", 20 } })
enterLobby()
shot("lobby")
ok(talkToMenu(), "the receptionist reaches Menu_ChallengeExplanationCancel")
shot("challenge-menu")
ok(isMenu() and #top().items == 3, "three rows: Challenge/Explanation/Cancel")
tap("a", 6)
local pages = {}
for _ = 1, 40 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
shot("rules-" .. #pages)
end
if not world:busy() and top() == nil then break end
tap("a", 4)
end
local joined = table.concat(pages, " | ")
say("pages: " .. joined)
ok(joined:find("You're not ready", 1, true) ~= nil,
"_ExcuseMeYoureNotReadyText printed")
ok(joined:find("Only three", 1, true) ~= nil,
"_OnlyThreeMonMayBeEnteredText printed")
ok(joined:find("Please return when", 1, true) ~= nil,
"_BattleTowerReturnWhenReadyText printed")
say("--- B: a legal party, the room menu, the walk out")
local savedQuickSave = Vm.SPECIALS.TryQuickSave
Vm.SPECIALS.TryQuickSave = function(vm) vm.scriptVar = 1 end
party({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20, "BERRY" },
{ "MEGANIUM", 20, "GOLD_BERRY" } })
enterLobby()
ok(talkToMenu(), "the desk reaches the menu again")
tap("a", 6)
local sawSavePrompt = false
for _ = 1, 120 do
if isRoomMenu() then break end
if world.choicebox then
sawSavePrompt = true
shot("save-prompt")
tap("a", 6)
else
tap("a", 4)
end
end
ok(sawSavePrompt, "Text_SaveBeforeEnteringBattleRoom asked first")
ok(isRoomMenu(), "special BattleTowerRoomMenu pushed Gen2BattleTowerMenu")
shot("room-menu")
if isRoomMenu() then
local screen = top()
ok(#screen.rows == BattleTower.PRE_HOF_LEVEL_GROUPS,
"no Hall of Fame, so four rooms are offered")
-- L:10 with an L20 party is the refusal at mobile_46.asm:3915-3917.
tap("a", 6)
ok(screen.phase == "message", "picking L:10 prints the level refusal")
shot("tops-this-level")
for _ = 1, 150 do
if screen.phase == "pick" then break end
U.wait(1)
end
ok(screen.phase == "pick", "and the menu comes back after the hold")
tap("up", 6)
shot("room-menu-l20")
tap("a", 6)
end
for _ = 1, 240 do
if not world:busy() and top() == nil then break end
tap("a", 4)
end
local tower = BattleTower.state(game.save)
ok(tower.levelGroup ~= nil, "the level group survived the menu")
say("levelGroup=" .. tostring(game.world.vm and game.world.vm.btLevelGroup)
.. " reward=" .. tostring(tower.reward)
.. " challenge=" .. tostring(tower.challenge)
.. " map=" .. tostring(world.map and world.map.id))
ok(tower.reward ~= nil,
"BATTLETOWERACTION_CHOOSEREWARD banked a prize on the way out")
shot("after-desk")
U.wait(180)
shot("elevator-or-hallway")
say("map after the walk: " .. tostring(world.map and world.map.id))
Vm.SPECIALS.TryQuickSave = savedQuickSave
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+249
View File
@@ -0,0 +1,249 @@
-- Smoke: the whole Crystal boot chain, with no driver shortcut.
--
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_boot_smoke.lua love .
--
-- copyright -> GameFreak -> Crystal intro -> title -> intro menu -> NEW GAME
-- -> clock -> Oak speech -> name pick -> naming screen -> the bedroom ->
-- start menu -> outdoors -> a wild battle. 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 CrystalIntro = require("src.ui.gen2.CrystalIntro")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GenderSelect = require("src.ui.gen2.GenderSelect")
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")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-boot"
local log = io.open(os.getenv("POKEPORT_BOOT_LOG")
or (out .. "/boot.log"), "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
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
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
local function pick(menu, value)
for i, item in ipairs(menu.list.items) do
if item.value == value then menu.list.index = i end
end
end
if GameVersion.get() ~= "crystal" then
bail("booted " .. tostring(GameVersion.get()) .. ", not crystal")
end
if GameVersion.engine() ~= "crystal" then
bail("engine lineage is " .. tostring(GameVersion.engine()))
end
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
U.wait(10)
if not isA(CopyrightSplash) then
bail("boot did not start at the copyright splash (top "
.. tostring(top()) .. ")")
end
say("OK copyright")
U.shot(game, out .. "/01-copyright.png")
waitFor("GameFreak presents", function() return isA(GameFreakPresents) end)
U.wait(90)
say("OK gamefreak")
U.shot(game, out .. "/02-gamefreak.png")
-- ../pokecrystal/engine/movie/intro.asm:1
waitFor("the Crystal intro", function() return isA(CrystalIntro) end)
U.wait(240)
say("OK crystal intro")
U.shot(game, out .. "/03-intro.png")
U.wait(240)
U.shot(game, out .. "/03b-intro.png")
tap("start")
waitFor("the title screen", function() return isA(TitleState) end)
U.wait(90)
say("OK title")
U.shot(game, out .. "/04-title.png")
for _ = 1, 3 do tap("start", 3) end
waitFor("the intro menu", function() return isA(MainMenu) end)
say("OK main menu")
U.shot(game, out .. "/05-mainmenu.png")
local menu = top()
pick(menu, "option")
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)
menu = top()
pick(menu, "new")
U.shot(game, out .. "/07-newgame.png")
tap("a")
-- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, which
-- PlayerProfileSetup runs first (engine/menus/intro_menu.asm:80-84).
waitFor("the gender screen", function() return isA(GenderSelect) end, 300)
say("OK gender select")
U.shot(game, out .. "/07b-gender.png")
tap("a")
-- ../pokecrystal/engine/menus/intro_menu.asm:628
waitFor("the clock screen", function() return isA(InitClock) end, 300)
say("OK init clock")
U.shot(game, out .. "/08-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)
local oak = top()
say("OK oak speech, demo mon = " .. tostring(oak.demoSpecies))
if oak.demoSpecies ~= "WOOPER" then
bail("Oak's demo mon is " .. tostring(oak.demoSpecies)
.. ", Crystal's is WOOPER")
end
U.wait(60)
U.shot(game, out .. "/09-oak.png")
local shotDemo = false
for _ = 1, 400 do
if isA(NamePick) then break end
if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then
U.wait(45)
U.shot(game, out .. "/10-wooper.png")
shotDemo = true
end
tap("a", 2)
end
if not isA(NamePick) then
bail("Oak speech never reached the name picker (top "
.. tostring(top()) .. ")")
end
say("OK name pick" .. (shotDemo and " (demo pic captured)" or ""))
-- ../pokecrystal/engine/menus/intro_menu.asm:738 NamePlayer
waitFor("the name menu to slide in",
function() return top().slide == nil end, 240)
U.shot(game, out .. "/11-namepick.png")
local picker = top()
picker.cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
tap("a")
tap("right")
tap("a")
if naming.text ~= "AB" then
bail("typed name is " .. tostring(naming.text) .. ", expected AB")
end
say("OK naming screen")
U.shot(game, out .. "/12-naming.png")
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
for _ = 1, 600 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
if game.save.player.name ~= "AB" then
bail("player name is " .. tostring(game.save.player.name))
end
if game.world.map.id ~= "PLAYERS_HOUSE_2F" then
bail("new game started on " .. tostring(game.world.map.id)
.. ", expected the bedroom")
end
say("OK overworld, map = " .. game.world.map.id
.. ", name = " .. tostring(game.save.player.name))
U.wait(20)
U.shot(game, out .. "/13-bedroom.png")
tap("start")
waitFor("the start menu", function() return isA(StartMenu) end)
say("OK start menu")
U.shot(game, out .. "/14-startmenu.png")
tap("b")
waitFor("the overworld again", function() return top() == nil end)
if not game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") then
bail("could not load NEW_BARK_TOWN")
end
U.wait(30)
if game.world.map.id ~= "NEW_BARK_TOWN" then
bail("setMap landed on " .. tostring(game.world.map.id))
end
local tileset = game.world.map.tileset or {}
say("OK outdoors, tileset = " .. tostring(tileset.id)
.. ", tilePalettes = " .. tostring(#(tileset.tilePalettes or {})))
U.shot(game, out .. "/15-newbark.png")
local player = Mon.new(game.data, "CYNDAQUIL", 12)
if not (player and #player.moves > 0) then
bail("could not build a CYNDAQUIL from the Crystal pokemon.lua")
end
local wild = Mon.new(game.data, "WOOPER", 5)
if not wild then bail("could not build a wild WOOPER") end
game.save.party = { player }
if not game.world:startBattle({ wild = wild }) then
bail("startBattle failed")
end
U.wait(240)
say("OK battle, " .. player.species .. " L" .. player.level
.. " vs " .. wild.species .. " L" .. wild.level)
U.shot(game, out .. "/16-battle.png")
say("PASS crystal boot chain in " .. out)
if log then log:close() end
love.event.quit(0)
end
+227
View File
@@ -0,0 +1,227 @@
-- TryQuickSave and the Crystal script VARs, in a real Crystal game.
--
-- POKEPORT_IDENTITY=f2-crystal POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/f2 \
-- POKEPORT_DRIVER=tests/drivers/crystal_f2_save_and_vars.lua love .
--
-- A ../pokecrystal/maps/BattleTower1F.asm:80-86 with NO monkeypatch: the
-- desk's own `writetext / yesorno`, then `special TryQuickSave` -- the
-- overwrite prompt, the SAVING pages and a real file on disk -- and the
-- room menu it can only reach on a TRUE.
-- B ../pokecrystal/maps/RadioTower2F.asm:135-146: the correct password's
-- `readvar / addval 1 / writevar VAR_BLUECARDBALANCE`, through the real
-- World, over a save and a reload, and then BuenaPrize spending it.
local U = require("tests.drivers.util")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local BuenaPassword = require("src.ui.gen2.BuenaPassword")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
local Specials = require("src.script.gen2.Specials")
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-f2"
local fails, shots = 0, 0
local function say(line)
print("[driver] " .. line)
io.stdout:flush()
end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
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
local function top() return game.stack:top() end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
local save = game.save
ok(Specials.STUB_REASONS.TryQuickSave == nil,
"TryQuickSave is a handler, not a stub")
ok(Specials.STUB_REASONS.SampleKenjiBreakCountdown == nil,
"SampleKenjiBreakCountdown is a handler too")
--------------------------------------------------------------- section B
say("--- B: Buena's Blue Card, over a save and a reload")
ok(world:setMap("RADIO_TOWER_2F", 5, 5, "down") == true, "on RADIO_TOWER_2F")
U.wait(30)
local crystal = Save.crystalState(save)
crystal.buenaPassword.balance = 0
crystal.buenaPassword.word = 0x00
crystal.buenaPassword.day = nil
-- The three rows the correct-answer arm runs, straight off the extracted
-- opcode table, so the round trip is World:readVar -> Vm -> World:writeVar.
local function awardPoint()
local started = world.vm:start({
{ op = "readvar", var = VAR_BLUECARDBALANCE },
{ op = "addval", args = { 1 } },
{ op = "writevar", var = VAR_BLUECARDBALANCE },
})
ok(started == true, "the award rows started (vm idle)")
for _ = 1, 120 do
if not world.vm:running() then break end
U.wait(1)
end
end
for _ = 1, 3 do awardPoint() end
say("blue card balance: " .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
ok(world:readVar(VAR_BLUECARDBALANCE) == 3,
"three correct passwords are three points")
ok(crystal.buenaPassword.balance == 3, "and the points are in save.crystal")
-- The password itself is a RETVAR_ADDR_DE row too; BuenasPassword parks the
-- day's roll there through the same writevar path.
world:writeVar(VAR_BUENASPASSWORD, 0x24)
ok(world:readVar(VAR_BUENASPASSWORD) == 0x24, "wBuenasPassword reads back")
-- The map load that used to eat it: scriptVars is rebuilt empty at
-- World.lua:579.
ok(world:setMap("RADIO_TOWER_1F", 5, 5, "down") == true, "walked downstairs")
U.wait(20)
ok(world:readVar(VAR_BLUECARDBALANCE) == 3, "a map load does not eat it")
ok(game:writeSave() ~= false, "saved with the balance on it")
local reloaded = Save.load(save.version)
local reloadedBalance = reloaded and reloaded.crystal
and reloaded.crystal.buenaPassword
and reloaded.crystal.buenaPassword.balance
say("reloaded blue card balance: " .. tostring(reloadedBalance))
ok(reloadedBalance == 3, "and it survives a save and a reload")
ok(reloaded and reloaded.crystal.buenaPassword.word == 0x24,
"so does the password")
-- BuenaPrize, which could never dispense anything on a balance of 0.
-- data/items/buena_prizes.asm's first row is ULTRA_BALL at 2 points.
save.inventory = save.inventory or {}
save.inventory.ULTRA_BALL = nil
local prizeIndex
for index, name in ipairs(world.vm.specialOrder or {}) do
if name == "BuenaPrize" then prizeIndex = index - 1 break end
end
ok(prizeIndex ~= nil, "BuenaPrize is in constants.specialOrder")
world.vm:start({ { op = "special", id = prizeIndex } })
local sawPrizeMenu = false
for _ = 1, 300 do
if not world.vm:running() and top() == nil then break end
if getmetatable(top()) == BuenaPassword then
if not sawPrizeMenu then
sawPrizeMenu = true
say("prize menu balance shown: " .. tostring(top().balance))
shot("prize-menu")
tap("a", 6) -- ULTRA BALL
else
tap("b", 6) -- second pass: leave the shop
end
else
tap("a", 4)
end
end
ok(sawPrizeMenu, "BuenaPrize opened its list")
say("ULTRA_BALL x" .. tostring(save.inventory.ULTRA_BALL)
.. " balance=" .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
ok((save.inventory.ULTRA_BALL or 0) >= 1, "the prize was handed over")
ok(world:readVar(VAR_BLUECARDBALANCE) == 1, "and two points were spent")
shot("after-prize")
--------------------------------------------------------------- section A
say("--- A: the Battle Tower desk, with the real TryQuickSave")
-- A file has to already exist for AskOverwriteSaveFile to have anything to
-- ask about (`ld a, [wSaveFileExists] / and a / jr z, .erase`).
ok(game:writeSave() ~= false, "a first save is on disk")
ok(Save.exists(save.version), "Save.exists agrees")
save.party = {}
for _, row in ipairs({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20 },
{ "MEGANIUM", 20 } }) do
local mon = Mon.new(game.data, row[1], row[2])
save.party[#save.party + 1] = mon
end
for _ = 1, 40 do
if top() == nil then break end
tap("b", 2)
end
ok(world:setMap("BATTLE_TOWER_1F", 7, 7, "up") == true, "on BATTLE_TOWER_1F")
U.wait(40)
shot("tower-lobby")
local function isMenu() return getmetatable(top()) == ScriptMenu end
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
local reached = false
for _ = 1, 200 do
if isMenu() then reached = true break end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
ok(reached, "the receptionist reaches Menu_ChallengeExplanationCancel")
tap("a", 6) -- CHALLENGE
-- Two yes/no prompts now, not one: Text_SaveBeforeEnteringBattleRoom and
-- then AskOverwriteSaveFile's own, which the stub never asked.
local prompts, pages = 0, {}
for _ = 1, 240 do
if isRoomMenu() then break end
if world.choicebox then
prompts = prompts + 1
shot("prompt-" .. prompts)
say("prompt " .. prompts .. ": " .. tostring(world.lastText))
tap("a", 6)
else
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
end
tap("a", 4)
end
end
say("pages: " .. table.concat(pages, " | "))
ok(prompts >= 2,
"AskOverwriteSaveFile asked its own question (" .. prompts .. " prompts)")
local joined = table.concat(pages, " | ")
ok(joined:find("SAVING", 1, true) ~= nil, "the SAVING page was shown")
ok(joined:find("saved", 1, true) ~= nil, "and SavedTheGameText")
ok(isRoomMenu(), "TryQuickSave answered TRUE and the room menu opened")
shot("room-menu")
local onDisk = Save.load(save.version)
ok(onDisk ~= nil, "the challenge's own save reached disk")
ok(onDisk and onDisk.position and onDisk.position.map == "BATTLE_TOWER_1F",
"and it was written from the lobby: "
.. tostring(onDisk and onDisk.position and onDisk.position.map))
-- The room menu's own header is STATICMENU_DISABLE_B, so the way out of the
-- desk is forward: pick a room and let the walk to the elevator finish.
for _ = 1, 400 do
if not world:busy() and top() == nil then break end
tap("a", 4)
end
say("map after the desk: " .. tostring(world.map and world.map.id))
for _ = 1, 40 do
if top() == nil then break end
game.stack:pop()
end
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+260
View File
@@ -0,0 +1,260 @@
-- U2 verification: ENGINE_PLAYER_IS_FEMALE end to end, plus the four gendered
-- pictures and the Crystal surf refusal. Not a suite; a shot harness.
--
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 POKEPORT_GENDER=girl \
-- POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_flag_shots.lua love .
local U = require("tests.drivers.util")
local BattleState = require("src.ui.gen2.BattleState")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local MainMenu = require("src.ui.gen2.MainMenu")
local Mon = require("src.battle.gen2.Mon")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local Screens = require("src.ui.Screens")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/u2-gender"
local want = (os.getenv("POKEPORT_GENDER") or "girl"):lower()
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[u2] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function done(code)
if log then log:close() end
love.event.quit(code)
if code ~= 0 then error("driver failed", 0) end
coroutine.yield()
end
local function bail(reason)
say("FAIL " .. reason)
done(1)
end
local function top() return game.stack:top() end
local function isA(class)
local s = top()
return s ~= nil and getmetatable(s) == 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
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return true end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
say("fixes.surfOntoNpc=" .. tostring(GameVersion.fixes().surfOntoNpc))
------------------------------------------------------------------ new game
U.wait(10)
for _ = 1, 1200 do
if isA(MainMenu) then break end
tap("start", 2)
end
waitFor("the main menu", function() return isA(MainMenu) end, 600)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
local gendered = false
for _ = 1, 400 do
if isA(GenderSelect) then gendered = true break end
if isA(OakSpeech) or isA(NamePick) then break end
U.wait(1)
end
if GameVersion.engine() == "crystal" then
if not gendered then bail("Crystal never offered InitGender") end
local pick = top()
pick.cursor = (want == "girl") and 2 or 1
U.shot(game, out .. "/00-gender.png")
say("OK gender screen, choosing " .. want)
tap("a")
else
if gendered then bail("Gold offered a gender prompt; it has none") end
say("OK no gender prompt on " .. GameVersion.get())
end
for _ = 1, 900 do
if game.phase == "play" and game.world and game.world.map then break end
if isA(NamingScreen) then
local naming = top()
naming.row = naming:bottomRow()
naming.col = 6
end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
local world = game.world
local save = game.save
say("OK overworld, name=" .. tostring(save.player.name)
.. " gender=" .. tostring(save.player.gender)
.. " sprite=" .. tostring(world:playerSpriteName()))
------------------------------------------------------ the engine flag itself
local id = FieldMoves.FEMALE_FLAG
say("FieldMoves.FEMALE_FLAG=" .. tostring(id))
if GameVersion.engine() == "crystal" then
if id ~= 99 then bail("female flag id is " .. tostring(id) .. ", want 99") end
local reads = world:engineFlag(id)
say("world:engineFlag(" .. id .. ")=" .. tostring(reads))
if reads ~= (want == "girl") then
bail("checkflag ENGINE_PLAYER_IS_FEMALE read " .. tostring(reads))
end
elseif id ~= nil then
bail("Gold bound a female flag id: " .. tostring(id))
end
------------------------------------------------------------- the three sites
local sites = {
{ "COPYCATS_HOUSE_2F", 3, 4, "up", "01-copycat" },
{ "ROUTE_37", 5, 9, "up", "02-route37" },
{ "POKECENTER_2F", 5, 5, "up", "03-pokecenter2f" },
}
for _, site in ipairs(sites) do
local mapId, x, y, facing, name = site[1], site[2], site[3], site[4], site[5]
if world.maps and world.maps[mapId] then
world:setMap(mapId, x, y, facing)
U.wait(40)
U.shot(game, out .. "/" .. name .. ".png")
local names = {}
for _, npc in ipairs(world.npcs or {}) do
names[#names + 1] = tostring(npc.def and npc.def.sprite)
end
say(name .. " map=" .. tostring(world.map.id)
.. " objects=" .. table.concat(names, ","))
else
say("SKIP " .. mapId .. ": not in this cache")
end
end
------------------------------------------------------------------- the pack
world:setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(20)
save.inventory = save.inventory or {}
save.inventory.POTION = 3
Screens.push(game, "Gen2PackMenu", { save = save, world = {},
onClose = function() game.stack:pop() end })
U.wait(20)
U.shot(game, out .. "/04-pack.png")
local packGfx = top() and top().gfx
local pals = packGfx and packGfx.gfx and packGfx.gfx.palettes
local menuGfx = game.data.gen2MenuGfx or {}
local femalePals = menuGfx.pack and menuGfx.pack.palettesFemale
say("pack palettes are the female set: "
.. tostring(pals ~= nil and pals == femalePals))
tap("b")
U.wait(10)
while top() do game.stack:pop() end
------------------------------------------------------------ battle back pic
local hud = menuGfx.battleHud or {}
say("battleHud.playerBack=" .. tostring(hud.playerBack)
.. " playerBackFemale=" .. tostring(hud.playerBackFemale))
save.party = { Mon.new(game.data, "CYNDAQUIL", 12) }
world:startBattle({ wild = Mon.new(game.data, "WOOPER", 5) })
local battle
for _ = 1, 600 do
if getmetatable(top()) == BattleState then battle = top() break end
U.wait(1)
end
if not battle then bail("startBattle never reached the battle screen") end
-- showPlayerTrainer is only true until SendOutPlayerMon; hold it so the
-- back pic is what the shot catches.
for _ = 1, 90 do
battle.showPlayerTrainer = true
U.wait(1)
end
battle.showPlayerTrainer = true
U.shot(game, out .. "/05-battle-backpic.png")
say("battle backpic path=" .. tostring(battle.playerBackPath))
while top() do game.stack:pop() end
U.wait(10)
------------------------------------------------------------- trainer card
Screens.push(game, "Gen2TrainerCard", { onClose = function()
game.stack:pop() end })
U.wait(30)
U.shot(game, out .. "/06-trainercard.png")
say("trainer card female arm=" .. tostring(top() and top().female))
tap("b")
U.wait(10)
while top() do game.stack:pop() end
------------------------------------------------------------- hall of fame
save.hallOfFame = nil
Screens.push(game, "Gen2HallOfFame", {
save = save, mode = "induct", text = world.text,
entry = { winCount = 1, mons = { { species = "CYNDAQUIL", level = 12,
nickname = "CYNDAQUIL", otId = 1234, gender = "male" } } },
onDone = function() game.stack:pop() end })
U.wait(20)
local hof = top()
if getmetatable(hof) ~= HallOfFame then
bail("Gen2HallOfFame did not push (top is " .. tostring(hof) .. ")")
end
-- The ceremony walks its own phases; freeze it so the two player cards can
-- be posed and shot.
hof.update = function() end
hof.scx, hof.scy = 0, 0
hof.phase = "player"
U.shot(game, out .. "/07-hof-trainerpic.png")
hof.phase = "playerBack"
U.shot(game, out .. "/08-hof-backpic.png")
say("hof backpic=" .. tostring(hof.playerBackPath)
.. " trainerPic=" .. tostring(hof.trainerPicPath))
while top() do game.stack:pop() end
U.wait(10)
------------------------------------------------------------- surf onto NPC
--
-- SurfFunction.TrySurf with a facing object: Crystal refuses, Gold does not
-- (../pokecrystal/engine/events/overworld.asm:364).
local ctx = {
save = { player = { badges = { FOG = true } } },
mon = { species = "LAPRAS" },
facing = "down",
facingColl = 0x29,
playerColl = 0x00,
playerState = FieldMoves.PLAYER_NORMAL,
facingObject = { id = "an NPC standing on the water" },
}
local refused = FieldMoves.surfFromMenu(ctx)
say("surf onto an NPC: ok=" .. tostring(refused.ok)
.. " text=" .. tostring(refused.text))
local shouldRefuse = GameVersion.fixes().surfOntoNpc == true
if (refused.ok ~= true) ~= shouldRefuse then
bail("surf-onto-NPC answered ok=" .. tostring(refused.ok)
.. " but fixes().surfOntoNpc=" .. tostring(shouldRefuse))
end
ctx.facingObject = nil
if not FieldMoves.surfFromMenu(ctx).ok then
bail("open water refused SURF")
end
say("PASS " .. GameVersion.get() .. " / " .. want .. " in " .. out)
done(0)
end
+234
View File
@@ -0,0 +1,234 @@
-- Crystal's InitGender screen, end to end, and the six things that follow from
-- the answer. POKEPORT_GENDER picks the arm: "girl", "boy" or "none" (Gold,
-- which must never see the screen at all).
--
-- POKEPORT_IDENTITY=<sandbox> POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_GENDER=girl POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_shots.lua love .
local U = require("tests.drivers.util")
local GenderSelect = require("src.ui.gen2.GenderSelect")
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 TrainerCard = require("src.ui.gen2.TrainerCard")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local Screens = require("src.ui.Screens")
return function(game)
local want = os.getenv("POKEPORT_GENDER") or "girl"
local out = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/crystal-gender-" .. want)
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
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
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
local function eq(got, wanted, label)
if got ~= wanted then
bail(("%s: got %s, want %s"):format(label, tostring(got),
tostring(wanted)))
end
say("OK " .. label .. " = " .. tostring(got))
end
say("version=" .. tostring(GameVersion.get())
.. " engine=" .. tostring(GameVersion.engine())
.. " gender=" .. want)
-- Straight to the intro menu; the boot chain itself is crystal_boot_smoke's.
for _ = 1, 900 do
if isA(MainMenu) then break end
tap("start", 2)
end
if not isA(MainMenu) then
for _ = 1, 400 do
if isA(MainMenu) then break end
tap("a", 2)
end
end
waitFor("the intro menu", function() return isA(MainMenu) end)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
if want == "none" then
-- Gold and Silver: PlayerProfileSetup has no InitGender to farcall.
waitFor("the clock screen", function() return isA(InitClock) end, 400)
if isA(GenderSelect) then bail("Gold put up the gender screen") end
say("OK no gender screen; NEW GAME went straight to the clock")
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
for _, step in ipairs(speech.steps or {}) do
if step.kind == "gender" then bail("Gold's speech grew a gender beat") end
end
eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock",
"the first beat")
U.wait(40)
U.shot(game, out .. "/01-oak.png")
eq(game.save.player.gender, "male", "save.player.gender")
say("PASS gold has no gender prompt")
if log then log:close() end
love.event.quit(0)
return
end
waitFor("the gender screen", function() return isA(GenderSelect) end, 600)
local gender = top()
U.wait(20)
say("OK gender screen, cursor = " .. tostring(gender.cursor))
U.shot(game, out .. "/01-genderselect.png")
if want == "girl" then
tap("down")
U.wait(10)
U.shot(game, out .. "/02-genderselect-girl.png")
else
U.shot(game, out .. "/02-genderselect-boy.png")
end
tap("a")
waitFor("the clock screen", function() return isA(InitClock) end, 400)
eq(game.save.player.gender, want == "girl" and "female" or "male",
"wPlayerGender after the answer")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"NamePlayer's InitName default")
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
eq(speech.steps[1].id, "gender_select", "the beat the speech opened on")
local shotPic = false
for _ = 1, 500 do
if isA(NamePick) then break end
if not shotPic and isA(OakSpeech) then
local at = top().steps and top().steps[top().step]
if at and at.id == "ask_player_name" then
U.wait(45)
local wanted = (want == "girl") and top().playerPicFemale
or top().playerPic
if top().pic ~= wanted then
bail("DrawIntroPlayerPic put up the wrong pic")
end
U.shot(game, out .. "/03-intropic.png")
shotPic = true
end
end
tap("a", 2)
end
waitFor("the name picker", function() return isA(NamePick) end, 200)
waitFor("the name menu to slide in",
function() return top().slide == nil end, 240)
local picker = top()
eq(picker.items[2], want == "girl" and "KRIS" or "CHRIS", "preset row 1")
eq(picker.items[3], want == "girl" and "AMANDA" or "MAT", "preset row 2")
U.shot(game, out .. "/04-namepick.png")
picker.cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
eq(naming.gender, want == "girl" and "female" or "male",
"the keyboard's header icon gender")
U.wait(20)
U.shot(game, out .. "/05-naming.png")
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
for _ = 1, 700 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
eq(game.world.map.id, "PLAYERS_HOUSE_2F", "the bedroom")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"the name that reached the save")
local sprite = game.world.player and game.world.player.spriteDef
eq(sprite and sprite.id, FieldMoves.playerSprite(game.save.player.gender),
"the overworld sheet")
U.wait(20)
U.shot(game, out .. "/06-bedroom.png")
-- Walk a step so the sheet is seen mid-stride rather than only standing.
U.hold(game, "down", 20)
U.wait(20)
U.shot(game, out .. "/07-bedroom-walk.png")
-- The card, opened directly: the START-menu route is Gen2StartMenu's and is
-- already covered by crystal_boot_smoke.
game.save.player.badges = { ZEPHYR = true, HIVE = true }
local card = Screens.push(game, "Gen2TrainerCard",
{ save = game.save, onClose = function() end })
if getmetatable(card) ~= TrainerCard then bail("the card did not open") end
eq(card.female, want == "girl", "GetCardPic picked KrisCardPic")
U.wait(30)
U.shot(game, out .. "/08-trainercard.png")
tap("right")
U.wait(20)
U.shot(game, out .. "/09-trainercard-badges.png")
game.stack:pop()
local gear = Screens.push(game, "Gen2Pokegear", { save = game.save })
U.wait(30)
local pals = gear.pals and gear:pals()
local femalePals = gear.gfx and gear.gfx.palettesFemale
if want == "girl" and femalePals and pals ~= femalePals then
bail("the gear kept MalePokegearPals for Kris")
end
if want == "boy" and femalePals and pals == femalePals then
bail("the gear took FemalePokegearPals for Chris")
end
say("OK pokegear palettes = " .. (pals == femalePals and "female" or "male"))
U.shot(game, out .. "/10-pokegear.png")
game.stack:pop()
say("PASS crystal gender run (" .. want .. ") in " .. out)
if log then log:close() end
love.event.quit(0)
end
+117
View File
@@ -0,0 +1,117 @@
local U = require("tests.drivers.util")
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
local TitleState = require("src.ui.gen2.TitleState")
local INTERVAL = 20
local LIMIT = 4000
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-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.acts) then
U.log("SKIP no crystal intro acts in this cache -- re-import crystal")
return
end
local finished = false
local intro = CrystalIntro.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
U.log(("scene %d at frame %d (scx=%02x scy=%02x)")
:format(scene, intro.frames, intro.scx % 256, intro.scy % 256))
end
if not finished then
U.shot(game, ("%s/intro-%04d-scene%02d.png")
:format(out, intro.frames, intro.scene))
shots = shots + 1
end
end
assert(finished,
"the movie never reached IntroScene28 (frame " .. intro.frames .. ")")
U.log(("movie done: %d shots over %d frames"):format(shots, intro.frames))
local skipped = false
local intro2 = CrystalIntro.new(game, {
onDone = function() skipped = true end,
})
game.stack:clear()
game.stack:push(intro2)
U.wait(45)
U.tap(game, "b")
U.wait(3)
assert(skipped and intro2.skipped, "a button did not skip the intro")
U.log("skip via B verified at frame " .. intro2.frames)
game:showTitle()
U.wait(1)
local title = game.stack:top()
assert(getmetatable(title) == TitleState,
"showTitle left " .. tostring(title) .. " on the stack")
assert(#title.suicuneColor == 4,
"title.lua has no 4-frame Suicune set -- stale cache?")
assert(title.entrance, "title.lua has no entrance block -- stale cache?")
assert(title.suicuneX == 48 and title.suicuneY == 96,
("Suicune is at (%s, %s), expected (48, 96)")
:format(tostring(title.suicuneX), tostring(title.suicuneY)))
for i = 1, 4 do
U.shot(game, ("%s/title-entrance-%d.png"):format(out, i))
U.wait(4)
end
for _ = 1, 90 do
if title.entranceScx == 0 then break end
U.wait(1)
end
assert(title.entranceScx == 0, "the entrance never landed")
assert(title.gemY == title.gemRestY,
("gem y %s did not land at %s with the entrance")
:format(tostring(title.gemY), tostring(title.gemRestY)))
U.wait(2)
for i = 1, 5 do
U.shot(game, ("%s/title-suicune-%d.png"):format(out, i))
U.wait(6)
end
if love.window and love.window.setMode then
for _, shape in ipairs({ { 1280, 720 }, { 1920, 500 }, { 480, 800 } }) do
love.window.setMode(shape[1], shape[2], { resizable = true })
U.wait(3)
U.shot(game, ("%s/title-wide-%dx%d.png"):format(out, shape[1], shape[2]))
end
love.window.setMode(1280, 720, { resizable = true })
end
local intro3 = CrystalIntro.new(game, {})
game.stack:clear()
game.stack:push(intro3)
local function runTo(target, extra)
for _ = 1, LIMIT do
if intro3.scene >= target then break end
U.wait(5)
end
assert(intro3.scene >= target,
"widescreen pass never reached scene " .. target)
U.wait(extra)
end
runTo(4, 40)
U.shot(game, out .. "/intro-wide-scene04.png")
runTo(10, 60)
U.shot(game, out .. "/intro-wide-scene10.png")
runTo(18, 20)
U.shot(game, out .. "/intro-wide-scene18.png")
intro3:skip()
U.log(("PASS crystal intro + title shots in %s"):format(out))
end
+278
View File
@@ -0,0 +1,278 @@
-- The Crystal story specials, driven in a real game.
--
-- POKEPORT_IDENTITY=unitb-crystal POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unitb \
-- POKEPORT_DRIVER=tests/drivers/crystal_story_shots.lua love .
--
-- Four sections, each entered by putting the player on the real map and
-- letting the extracted script run:
-- A TIN_TOWER_1F the Suicune confrontation, and whether it flees
-- B ILEX_FOREST the GS Ball shrine, Celebi, and CheckCaughtCelebi
-- C DRAGON_SHRINE GiveDratini's Extremespeed moveset
-- D DAY_CARE GiveOddEgg
local U = require("tests.drivers.util")
local BattleState = require("src.ui.gen2.BattleState")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local Roamers = require("src.core.gen2.Roamers")
local Save = require("src.core.gen2.Save")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-story"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
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
local function top() return game.stack:top() end
local function battleState()
local st = top()
if st ~= nil and getmetatable(st) == BattleState then return st end
return nil
end
local function inBattle()
local st = battleState()
return st and st.battle or nil
end
local function waitFor(pred, frames)
for _ = 1, frames or 1200 do
if pred() then return true end
U.wait(1)
end
return false
end
local world
-- Mash A until the script is finished and the stack is bare again. The
-- naming screen gets B: `givepoke` ends in Specials.askNickname
-- (src/script/gen2/Specials.lua:382) and mashing A there types letters
-- forever instead of leaving.
-- The naming screen has no B exit: crystal_boot_smoke.lua leaves it by
-- putting the cursor on the bottom row's confirm cell. Confirming a blank
-- name is the cart's own "no nickname" (home/string.asm:6 _InitString).
local function mash()
local st = top()
if getmetatable(st) == NamingScreen then
st.row, st.col = st:bottomRow(), 6
end
tap("a", 2)
end
local function settle(frames)
for _ = 1, frames or 3000 do
if not world:busy() and top() == nil then return true end
mash()
end
local meta, name = getmetatable(top()), "?"
for id, mod in pairs(package.loaded) do
if mod == meta then name = id end
end
say((" stuck on %s (phase=%s) busy=%s"):format(name,
tostring(top() and top().phase), tostring(world:busy())))
return false
end
-- Stand one cell below the object carrying `scriptKey`, facing it.
local function standBelow(mapId, scriptKey)
world:setMap(mapId, 1, 1, "down")
U.wait(10)
local tx, ty
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.scriptKey == scriptKey then
tx, ty = npc.cellX, npc.cellY
end
end
if not tx then return false end
world:setMap(mapId, tx, ty + 1, "up")
U.wait(30)
return world.player.cellX == tx and world.player.cellY == ty + 1
end
local function moveIds(mon)
local list = {}
for _, move in ipairs((mon and mon.moves) or {}) do
list[#list + 1] = move.id
end
return table.concat(list, ",")
end
U.wait(60)
world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local save = game.save
save.player = save.player or {}
save.player.name = save.player.name or "CHRIS"
save.player.id = save.player.id or 30000
save.inventory = save.inventory or {}
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
-- ---- A TIN_TOWER_1F ----------------------------------------------------
-- ../pokecrystal/maps/TinTower1F.asm:84 TinTower1FSuicuneBattleScript, run
-- from the SCENE_TINTOWER1F_SUICUNE_BATTLE scene script at :22.
say("A Tin Tower")
say(" crystal AlwaysFleeMons SUICUNE = "
.. tostring(Roamers.alwaysFleeMons("crystal").SUICUNE)
.. ", gold = " .. tostring(Roamers.alwaysFleeMons("gold").SUICUNE))
world.mapScenes.TIN_TOWER_1F = 0
world:setMap("TIN_TOWER_1F", 9, 14, "up")
U.wait(20)
U.shot(game, out .. "/a1-tintower-enter.png")
ok(waitFor(function() return world:busy() end, 600),
"the scene script started on map entry")
U.wait(120)
U.shot(game, out .. "/a2-beasts.png")
ok(waitFor(function() return inBattle() ~= nil end, 3000),
"the Tin Tower scene reached a battle")
local battle = inBattle()
if battle then
U.wait(90)
U.shot(game, out .. "/a3-suicune-battle.png")
ok(battle.enemy and battle.enemy.species == "SUICUNE",
"the wild mon is SUICUNE (got " ..
tostring(battle.enemy and battle.enemy.species) .. ")")
ok((battle.enemy and battle.enemy.level) == 40, "at level 40")
-- ../pokecrystal/engine/battle/core.asm:759 TryEnemyFlee: with Suicune off
-- AlwaysFleeMons it has to survive the two random gates as well.
local fled = 0
for _ = 1, 500 do
if battle:tryEnemyFlee() then fled = fled + 1 end
end
ok(fled == 0, "Suicune never flees in 500 rolls (fled " .. fled .. ")")
ok(battle.outcome == nil, "and the battle is still live")
battle.outcome = "run"
local st = battleState()
if st then st:finishBattle() end
end
ok(settle(2000), "the Tin Tower script ran to its end")
-- ---- B ILEX_FOREST -----------------------------------------------------
-- ../pokecrystal/maps/IlexForest.asm:429 IlexForestShrineScript, a BGEVENT_UP
-- at (8,22). EVENT_FOREST_IS_RESTLESS and the GS Ball are the two gates; on
-- a retail cart neither can be reached, so both are set here the way the
-- Virtual Console wrapper sets sGSBallFlag
-- (../pokecrystal/engine/menus/save.asm:168).
say("B Ilex Forest shrine")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
Save.crystalState(save).gsBall = "have"
save.inventory.GS_BALL = 1
save.inventory.MASTER_BALL = 5
world:setMap("ILEX_FOREST", 8, 23, "up")
U.wait(40)
world.events:set(192, true)
U.shot(game, out .. "/b1-shrine.png")
tap("a", 30)
U.shot(game, out .. "/b2-shrine-prompt.png")
-- The prompt is the script's own yesorno; A takes YES.
ok(waitFor(function()
if inBattle() then return true end
tap("a", 3)
return false
end, 400), "the shrine script reached a battle")
local celebi = inBattle()
if celebi then
U.wait(90)
U.shot(game, out .. "/b3-celebi.png")
ok(celebi.enemy and celebi.enemy.species == "CELEBI",
"the wild mon is CELEBI (got " ..
tostring(celebi.enemy and celebi.enemy.species) .. ")")
ok(celebi.battleType == 11,
"the battle carries BATTLETYPE_CELEBI (got "
.. tostring(celebi.battleType) .. ")")
-- Catch it with a MASTER BALL so CheckCaughtCelebi has something to read.
local thrown = false
for _ = 1, 900 do
local st = battleState()
if not st then break end
if not thrown and st.phase == "menu" then
st:useItem("MASTER_BALL")
thrown = true
U.wait(60)
U.shot(game, out .. "/b4-masterball.png")
end
mash()
end
end
ok(settle(2000), "the shrine script ran to its end")
U.shot(game, out .. "/b5-after-celebi.png")
local caught = false
for _, mon in ipairs(save.party) do
if mon.species == "CELEBI" then caught = true end
end
ok(caught, "Celebi is in the party")
ok(Save.crystalState(save).celebiCaught == true,
"CheckCaughtCelebi recorded the catch")
ok((save.inventory.GS_BALL or 0) == 0, "the GS Ball was taken")
-- ---- C DRAGON_SHRINE ---------------------------------------------------
-- ../pokecrystal/maps/DragonShrine.asm:192 DragonShrineElder1Script, whose
-- .GiveDratini arm (:208) is `givepoke DRATINI, 15 / checkevent
-- EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG / special GiveDratini`.
say("C Dragon Shrine")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
-- ../pokecrystal/maps/DragonShrine.asm:10 SCENE_DRAGONSHRINE_NOOP; scene 0 is
-- the Dragon Master quiz cutscene, which is not what this section measures.
world.mapScenes.DRAGON_SHRINE = 1
ok(standBelow("DRAGON_SHRINE", "63:51a5"), "found the shrine elder")
U.shot(game, out .. "/c1-shrine.png")
tap("a", 30)
U.shot(game, out .. "/c2-elder.png")
ok(settle(2000), "the elder's script ran to its end")
U.shot(game, out .. "/c3-dratini.png")
local dratini
for _, mon in ipairs(save.party) do
if mon.species == "DRATINI" then dratini = mon end
end
ok(dratini ~= nil, "the elder handed over a DRATINI")
if dratini then
say(" moves = " .. moveIds(dratini))
ok(moveIds(dratini) == "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED",
"with .Moveset0, Extremespeed and all")
ok(dratini.moves[4].pp == 5, "Extremespeed arrives with 5 PP")
end
-- ---- D DAY_CARE --------------------------------------------------------
-- ../pokecrystal/maps/DayCare.asm:23 DayCareManScript_Inside, whose
-- `special GiveOddEgg` is at :33.
say("D Day Care")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
save.inventory.EGG_TICKET = 1
ok(standBelow("DAY_CARE", "18:6f8f"), "found the Day-Care Man")
U.shot(game, out .. "/d1-daycare.png")
tap("a", 30)
U.shot(game, out .. "/d2-gramps.png")
ok(settle(2000), "the Day-Care Man's script ran to its end")
U.shot(game, out .. "/d3-oddegg.png")
local egg = save.party[2]
ok(egg ~= nil and egg.isEgg == true, "an EGG joined the party")
if egg then
say((" %s ot=%s otId=%s eggSteps=%s moves=%s"):format(
tostring(egg.species), tostring(egg.ot), tostring(egg.otId),
tostring(egg.eggSteps), moveIds(egg)))
ok(egg.ot == "ODD", "with OT ODD")
ok(egg.eggSteps == 20, "and 20 hatch cycles")
end
ok((save.inventory.EGG_TICKET or 0) == 0, "the EGG TICKET was tossed")
say(fails == 0 and ("PASS crystal story in " .. out)
or ("FAIL " .. fails .. " checks, shots in " .. out))
love.event.quit(fails == 0 and 0 or 1)
end
+277
View File
@@ -0,0 +1,277 @@
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
-- opponent draw, the walk-in, the battle, and the counter the room script
-- reads back.
--
-- POKEPORT_IDENTITY=f1-tower POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
-- POKEPORT_DRIVER=tests/drivers/crystal_tower_battle_f1.lua love .
--
-- src/world/gen2/World.lua has no startTowerBattle / setObjectSprite hook and
-- World:startBattle does not forward `battleTower` into Battle.new, so the
-- three seams are shimmed here for the length of the run. The bodies are
-- exactly what belongs in World:specialHooks and World:startBattle.
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local World = require("src.world.gen2.World")
-- The world exists before a POKEPORT_DRIVER chunk loads (main.lua:426-438),
-- so the seams go on the live instance.
local function shimWorldSeams(world)
local towerBattle = false
local baseNew = Battle.new
Battle.new = function(opts)
if towerBattle then opts.battleTower = true end
return baseNew(opts)
end
local baseStartBattle = World.startBattle
world.startBattle = function(self, opts, onDone)
towerBattle = (opts and opts.battleTower) and true or false
local started = baseStartBattle(self, opts, onDone)
towerBattle = false
return started
end
local hooks = world.vm.specials
hooks.startTowerBattle = function(trainer, onDone)
return world:startBattle({ trainer = trainer, battleTower = true }, onDone)
end
hooks.setObjectSprite = function(objectId, spriteName)
local index = (objectId or 0) - 1
local def = world.map and world.map.def
local obj = def and def.objects and def.objects[index]
local sheet = world.sprites and world.sprites[spriteName]
if not (obj and sheet) then return false end
obj.sprite = spriteName
local npc = world:objectEntity(objectId)
if npc and npc:setSpriteDef(sheet) then world:applySpritePalette(npc) end
world:rebuildPeople({ seamless = true })
return true
end
end
-- ---- the run --------------------------------------------------------------
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
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(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
shimWorldSeams(world)
ok(type(world.vm.specials.startTowerBattle) == "function",
"the shimmed startTowerBattle hook reached the VM")
ok(type(world.vm.specials.setObjectSprite) == "function",
"and so did setObjectSprite")
local roster = BattleTower.roster(game.data)
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
if roster then
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
roster.sampleTrainers))
end
-- Three legal mons over the L10 room, each with ONE strong move so a blind
-- A-press run cannot pick a status move for a hundred turns.
local party = {}
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
{ "FERALIGATR", 60, "SURF", "BERRY" },
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
local def = game.data.moves[row[3]]
local mon = Mon.new(game.data, row[1], row[2], {
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
})
mon.item = row[4]
party[#party + 1] = mon
end
game.save.party = party
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
-- the L10 one (battle_tower.asm:1129-1141).
local tower = BattleTower.state(game.save)
tower.levelGroup = 1
tower.streak = 0
tower.trainers = {}
tower.challenge = BattleTower.NO_CHALLENGE
while game.stack:top() do game.stack:pop() end
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
U.wait(30)
shot("room-entered")
-- The scene script walks the player in, draws the opponent and starts the
-- battle; nothing here presses anything until a text box wants it.
local sawOpponent, sawBattle = false, false
for _ = 1, 900 do
local vm = world.vm
if vm and vm.btOpponent and not sawOpponent then
sawOpponent = true
say(("opponent: %s %s (row %d), sprite %s, group %d")
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
vm.btOpponent.group))
for slot, mon in ipairs(vm.btOpponent.rows) do
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
tostring(mon.item)))
end
U.wait(20)
shot("opponent-walked-in")
end
if world.battleActive then
sawBattle = true
break
end
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
end
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
local screen, battle = nil, nil
if sawBattle then
U.wait(90)
shot("battle-open")
screen = game.stack:top()
battle = screen and screen.battle
ok(battle ~= nil, "the battle screen owns a Battle")
if battle then
ok(battle.inBattleTowerBattle == true,
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
.. " L" .. tostring(battle.enemy and battle.enemy.level))
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
"with a three-mon Tower party")
end
end
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
-- move with PP left.
local function hardestMove(mon)
local best, bestPower = 1, -1
for index, move in ipairs((mon and mon.moves) or {}) do
local def = game.data.moves and game.data.moves[move.id]
local power = (def and def.power) or 0
if (move.pp or 0) > 0 and power > bestPower then
best, bestPower = index, power
end
end
return best
end
-- PP topped up between turns: an unattended run otherwise Struggles itself
-- to death long before the third opponent mon is down.
local function refill(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
move.pp = move.maxPp or move.pp
end
end
local menus = 0
for _ = 1, 900 do
if not world.battleActive or (battle and battle.over) then break end
local phase = screen and screen.phase
if phase == "menu" then
menus = menus + 1
if menus == 1 then shot("battle-menu") end
refill(battle and battle.player)
screen.menuIndex = 1
tap("a", 4)
elseif phase == "moves" then
screen.menuIndex = hardestMove(battle and battle.player)
tap("a", 4)
elseif phase == "submenu" then
-- A forced switch cannot be cancelled, so walk off the fainted lead.
tap("down", 3)
tap("a", 4)
else
tap("a", 3)
end
end
if battle then
say("battle over=" .. tostring(battle.over)
.. " outcome=" .. tostring(battle.outcome)
.. " phase=" .. tostring(screen and screen.phase))
ok(battle.over, "the battle resolved")
ok(battle.outcome == "win", "and the L60 party won it")
shot("battle-end")
end
for _ = 1, 200 do
if not world.battleActive then break end
tap("a", 3)
end
ok(not world.battleActive, "the battle screen came down")
U.wait(30)
shot("after-battle")
local vm = world.vm
local state = BattleTower.state(game.save)
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
tostring(vm and vm.stringBuffer)))
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
"and armed sBattleTowerChallengeState")
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
ok(state.prevTeams and #state.prevTeams.prev == 3,
"and sBTMonPrevTrainer recorded the team")
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
-- second draw all follow on their own.
local pages, secondDraw = {}, nil
for _ = 1, 400 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
if #pages <= 6 then shot("page-" .. #pages) end
end
if world.battleActive then
secondDraw = world.vm and world.vm.btOpponent
break
end
if world.choicebox then tap("a", 4) else tap("a", 3) end
end
say("pages: " .. table.concat(pages, " | "))
local joined = table.concat(pages, " | ")
ok(joined:find("healed to full health", 1, true) ~= nil
or joined:find("Next up", 1, true) ~= nil,
"the room script carried on past the battle")
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
or joined:find("no.2", 1, true) ~= nil,
"and wStringBuffer3 named the SECOND opponent")
if world.battleActive then
U.wait(60)
shot("second-battle")
ok(true, "the loop came back round for opponent two")
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
end
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+182
View File
@@ -0,0 +1,182 @@
-- The four Ruins of Alph secret chambers, opened in a real game.
--
-- POKEPORT_IDENTITY=f3-crystal POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/f3 \
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_chambers.lua love .
local U = require("tests.drivers.util")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:25 closed, :42 open;
-- ../pokecrystal/engine/overworld/scripting.asm:2147 halves them to block 2,0.
local WALL_BLOCK = 3
local WALL_SHUT, WALL_OPEN = 0x2e, 0x30
local CHAMBERS = {
{
key = "OMANYTE", word = "WATER",
-- ../pokecrystal/engine/events/unown_walls.asm:25 CheckItem, :38 MON_ITEM
shut = function(game)
game.save.inventory = { FIRE_STONE = 1 }
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
{ item = "FIRE_STONE" }) }
end,
arm = function(game)
game.save.inventory = {}
game.save.party = {
Mon.new(game.data, "TYPHLOSION", 40),
Mon.new(game.data, "LANTURN", 30, { item = "WATER_STONE" }),
}
end,
armed = "a WATER STONE held by the last party mon",
},
{
key = "HO_OH", word = "HO-OH",
-- ../pokecrystal/engine/events/unown_walls.asm:2 wPartySpecies[0].
shut = function(game)
game.save.inventory = {}
game.save.party = {
Mon.new(game.data, "TYPHLOSION", 40),
Mon.new(game.data, "HO_OH", 70),
}
end,
arm = function(game)
game.save.party = {
Mon.new(game.data, "HO_OH", 70),
Mon.new(game.data, "TYPHLOSION", 40),
}
end,
armed = "HO-OH moved into the FIRST party slot",
},
{
key = "KABUTO", word = "ESCAPE",
shut = function(game)
game.save.inventory = { ESCAPE_ROPE = 1 }
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
end,
-- ../pokecrystal/engine/events/overworld.asm:809 farcall
-- SpecialKabutoChamber, off EscapeRopeOrDig's rope arm.
arm = function(_, world)
UnownWords.kabutoChamber(world.events, world.map and world.map.id)
end,
armed = "SpecialKabutoChamber, as the escape rope calls it",
},
{
key = "AERODACTYL", word = "LIGHT",
shut = function(game)
game.save.inventory = {}
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
end,
-- ../pokecrystal/engine/events/overworld.asm:285 farcall
-- SpecialAerodactylChamber, inside FlashFunction.CheckUseFlash.
arm = function(_, world)
UnownWords.aerodactylChamber(world.events, world.map and world.map.id)
end,
armed = "SpecialAerodactylChamber, as FLASH calls it",
},
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-chambers"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
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
local function wordScreen()
local state = game.stack:top()
return (state and getmetatable(state) == UnownWords) and state or nil
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "CHRIS"
game.save.player.id = game.save.player.id or 30000
local function enter(mapId)
world:setMap("RUINS_OF_ALPH_OUTSIDE", 8, 15, "down")
U.wait(20)
world.mapScenes[mapId] = 0
world:setMap(mapId, 3, 1, "up")
for _ = 1, 400 do
U.wait(2)
if not world:busy() and not world.pendingSceneScript then break end
end
U.wait(30)
end
local function blockNow()
local blocks = world.map and world.map.def and world.map.def.blocks
return blocks and blocks[WALL_BLOCK]
end
for index, chamber in ipairs(CHAMBERS) do
local mapId = UnownWords.CHAMBER_MAPS[chamber.key]
local flag = UnownWords.WALL_OPENED[chamber.key]
say(("%d %s (flag %d)"):format(index, mapId, flag))
world.events:set(flag, false)
chamber.shut(game, world)
enter(mapId)
ok(world.map and world.map.id == mapId, " stood in the chamber")
ok(not world.events:get(flag), " the wall flag is still clear")
ok(blockNow() == WALL_SHUT,
(" and the hidden-doors callback drew the closed wall (%s)")
:format(tostring(blockNow())))
U.shot(game, ("%s/%d-%s-shut.png"):format(out, index,
chamber.key:lower()))
chamber.arm(game, world)
say(" armed: " .. chamber.armed)
world.mapScenes[mapId] = 0
enter(mapId)
ok(world.events:get(flag), " the wall flag is set now")
ok(blockNow() == WALL_OPEN,
(" and the wall-open script rewrote the block (%s)")
:format(tostring(blockNow())))
U.shot(game, ("%s/%d-%s-open.png"):format(out, index,
chamber.key:lower()))
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:82
-- RuinsOfAlphOmanyteChamberWallPatternLeft
local screen
for _ = 1, 200 do
screen = wordScreen()
if screen then break end
tap("a", 2)
end
ok(screen ~= nil, " the wall pattern reached DisplayUnownWords")
if screen then
U.wait(20)
ok(screen.wall and screen.wall.word == chamber.word,
(" showing %s (got %s)"):format(chamber.word,
tostring(screen.wall and screen.wall.word)))
U.shot(game, ("%s/%d-%s-word.png"):format(out, index,
chamber.key:lower()))
tap("a", 10)
U.wait(20)
end
end
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
U.wait(10)
love.event.quit(fails == 0 and 0 or 1)
end
+104
View File
@@ -0,0 +1,104 @@
-- The Ruins of Alph wall words and word rooms, driven in a real game.
--
-- POKEPORT_IDENTITY=unit-e-unown POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unit-e \
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_words_shots.lua love .
local U = require("tests.drivers.util")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
-- maps/RuinsOfAlphKabutoChamber.asm:272 `bg_event 4, 0, BGEVENT_UP`
local CHAMBERS = {
{ map = "RUINS_OF_ALPH_KABUTO_CHAMBER", word = "ESCAPE" },
{ map = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", word = "LIGHT" },
{ map = "RUINS_OF_ALPH_OMANYTE_CHAMBER", word = "WATER" },
{ map = "RUINS_OF_ALPH_HO_OH_CHAMBER", word = "HO-OH" },
}
local WORD_ROOMS = {
{ map = "RUINS_OF_ALPH_KABUTO_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_HO_OH_WORD_ROOM", x = 9, y = 6 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-words"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
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
local function top() return game.stack:top() end
local function wordScreen()
local state = top()
return (state and getmetatable(state) == UnownWords) and state or nil
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local save = game.save
save.player = save.player or {}
save.player.name = save.player.name or "CHRIS"
save.player.id = save.player.id or 30000
save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
for index, chamber in ipairs(CHAMBERS) do
say("A" .. index .. " " .. chamber.map)
world:setMap(chamber.map, 4, 1, "up")
U.wait(30)
U.shot(game, ("%s/a%d-%s-chamber.png"):format(
out, index, chamber.word:lower()))
local screen
for _ = 1, 300 do
screen = wordScreen()
if screen then break end
if not world:busy() then tap("a", 2) else tap("a", 2) end
end
ok(screen ~= nil, chamber.map .. " reached DisplayUnownWords")
if screen then
U.wait(20)
ok(screen.wall and screen.wall.word == chamber.word,
(" showing %s (got %s)"):format(chamber.word,
tostring(screen.wall and screen.wall.word)))
ok(#screen.squares == #chamber.word,
(" %d letter squares"):format(#screen.squares))
U.shot(game, ("%s/a%d-%s-word.png"):format(
out, index, chamber.word:lower()))
tap("a", 10)
ok(wordScreen() == nil, " A closes the box")
U.wait(20)
end
end
for index, room in ipairs(WORD_ROOMS) do
say("B" .. index .. " " .. room.map)
world:setMap(room.map, room.x, room.y, "up")
U.wait(40)
ok(world.map and world.map.id == room.map, " stood in " .. room.map)
U.shot(game, ("%s/b%d-%s.png"):format(out, index,
room.map:lower():gsub("ruins_of_alph_", "")))
end
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
U.wait(10)
love.event.quit(fails == 0 and 0 or 1)
end
+119
View File
@@ -0,0 +1,119 @@
-- The two chamber walls the FIELD MOVES open, driven through the production
-- call sites rather than by calling the routines directly.
--
-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash
-- (badge FIRST, then SpecialAerodactylChamber) and :808-813 EscapeRopeOrDig's
-- `.escaperope` arm.
--
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_DRIVER=tests/drivers/gate_chamber_fieldmoves.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
return function(game)
local fails = 0
local function say(line) print("[driver] " .. line); io.stdout:flush() end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal 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 4)
end
local function clearText()
for _ = 1, 200 do
if not world:busy() then return true end
tap("a", 3)
end
return false
end
local function enter(mapId)
while game.stack:top() do game.stack:pop() end
assert(world:setMap(mapId, 3, 7, "up"), "no " .. mapId .. " in this cache")
for _ = 1, 240 do
if not world:busy() then break end
U.wait(2)
end
U.wait(10)
end
-- ---- the escape rope, in the Kabuto chamber ------------------------------
say("--- KABUTO, through World:useEscapeRope()")
enter(UnownWords.CHAMBER_MAPS.KABUTO)
ok(not UnownWords.wallOpened(world.events, "KABUTO"),
"the Kabuto wall flag starts clear")
game.save.inventory = { ESCAPE_ROPE = 1 }
-- wDigWarpNumber / wBackupMapGroup: the cave's own entrance, which
-- ../pokecrystal/engine/events/overworld.asm:795-798 copies into wNextWarp.
world.backupWarp = { map = "RUINS_OF_ALPH_OUTSIDE", warp = 1 }
clearText()
local rope = world:useEscapeRope("ESCAPE_ROPE")
say("escape rope result: " .. tostring(rope))
ok(rope == "escape_rope", "the rope was used")
ok(UnownWords.wallOpened(world.events, "KABUTO"),
"and SpecialKabutoChamber set the wall flag")
-- The rope must not open a wall anywhere else.
world.queuedFieldMove = nil
enter("RUINS_OF_ALPH_OUTSIDE")
clearText()
local before = UnownWords.wallOpened(world.events, "OMANYTE")
game.save.inventory = { ESCAPE_ROPE = 1 }
world:useEscapeRope("ESCAPE_ROPE")
ok(UnownWords.wallOpened(world.events, "OMANYTE") == before,
"a rope used outside a chamber opens nothing")
-- ---- FLASH, in the Aerodactyl chamber ------------------------------------
say("--- AERODACTYL, through World:useFieldMove(\"FLASH\")")
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
ok(world.map.id == UnownWords.CHAMBER_MAPS.AERODACTYL, "in the chamber")
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
"the wall flag starts clear")
local flash = game.data.moves.FLASH
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
{ moves = { { id = "FLASH", pp = flash.pp, maxPp = flash.pp } } }) }
-- :281-283, the ZEPHYRBADGE gate that runs BEFORE the special.
game.save.player = game.save.player or {}
game.save.player.badges = {}
local refused = world:useFieldMove("FLASH", game.save.party[1])
ok(refused and refused.ok ~= true, "no ZEPHYRBADGE: FLASH is refused")
ok(refused and refused.badge == "ZEPHYR", "on the badge, not on the map")
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
"and the badgeless press did NOT open the wall")
-- The refusal opened a text box; the world is busy until it is dismissed.
clearText()
game.save.player.badges = { ZEPHYR = true }
local used = world:useFieldMove("FLASH", game.save.party[1])
say("flash result: ok=" .. tostring(used and used.ok)
.. " action=" .. tostring(used and used.action))
ok(used and used.ok == true,
"with the badge FLASH is allowed in a chamber that is not a dark cave")
ok(UnownWords.wallOpened(world.events, "AERODACTYL"),
"and SpecialAerodactylChamber set the wall flag")
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
U.wait(40)
local _, block = world:blockIndexAt(3, 5)
say("aerodactyl block under the wall: " .. tostring(block))
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp")
.. "/01-aerodactyl-flash-open.png")
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+80
View File
@@ -0,0 +1,80 @@
-- Gate check: the Crystal wave left Gold alone. No Battle Tower, no Buena's
-- Blue Card, no Ruins of Alph secret chambers, and the three beasts roam.
--
-- POKEPORT_GAME=gold POKEPORT_VERSION=gold \
-- POKEPORT_DRIVER=tests/drivers/gate_gold_untouched.lua love .
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local Roamers = require("src.core.gen2.Roamers")
return function(game)
local fails = 0
local function say(line) print("[driver] " .. line); io.stdout:flush() end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
assert(world and world.map, "gold world did not boot")
say("version=" .. tostring(game.version) .. " map=" .. tostring(world.map.id))
local order = {}
for _, name in ipairs((world.constants or {}).specialOrder or {}) do
order[name] = true
end
ok(next(order) ~= nil, "the Gold cache names its specials")
for _, name in ipairs({ "BattleTowerBattle", "BattleTowerAction",
"BattleTowerRoomMenu", "LoadOpponentTrainerAndPokemonWithOTSprite",
"BuenasPassword", "BuenaPrize", "AskRememberPassword",
"OmanyteChamber", "HoOhChamber", "CelebiShrineEvent",
"SampleKenjiBreakCountdown", "MoveTutor", "PokeSeer" }) do
ok(not order[name], "Gold has no " .. name .. " special")
end
ok(world.maps.BATTLE_TOWER_1F == nil, "no BATTLE_TOWER_1F map")
ok(world.maps.BATTLE_TOWER_BATTLE_ROOM == nil, "no tower battle room")
ok((world.trainers or {}).battleTower == nil,
"trainers.lua carries no battleTower roster")
ok((world.eventTables or {}).unownWalls == nil,
"events.lua carries no unownWalls table")
-- ../pokecrystal/constants/event_flags.asm:486-489 are Crystal-only.
for _, flag in ipairs({ 806, 807, 808, 809 }) do
ok(not world.events:get(flag), "wall-opened flag " .. flag .. " is clear")
end
-- ../pokecrystal/constants/script_constants.asm:69-74, Crystal-only VARs.
for _, id in ipairs({ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a }) do
ok(world:readVar(id) == 0,
("VAR $%02x reads 0 on Gold"):format(id))
end
-- data/wild/roammon_maps.asm: RAIKOU, ENTEI and SUICUNE, all three.
local roster = Roamers.roster(world.encounters)
say("roamer roster: " .. #roster .. " rows")
for _, row in ipairs(roster) do
say((" %s L%s start=%s"):format(tostring(row.species),
tostring(row.level), tostring(row.map)))
end
ok(#roster == 3, "three beasts in the roster")
local list = Roamers.init(game.save, { encounters = world.encounters, force = true })
local live = 0
for _, slot in ipairs(list or {}) do
if Roamers.active(slot) then live = live + 1 end
end
ok(live == 3, "all three are active after InitRoamMons")
-- pokegold/engine/battle/core.asm:3476-3479: TRAP and FORCESHINY only.
ok(Battle.noEscapeBattleType({ battleType = 9 }) == true, "TRAP: no escape")
ok(Battle.noEscapeBattleType({ battleType = 7 }) == true,
"FORCESHINY: no escape")
ok(Battle.noEscapeBattleType({ battleType = 10 }) == false,
"FORCEITEM: Lugia and Ho-Oh can still be run from on Gold")
ok(Battle.noEscapeBattleType({ battleType = 8 }) == false, "TREE: escapable")
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+236
View File
@@ -0,0 +1,236 @@
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
-- opponent draw, the walk-in, the battle, and the counter the room script
-- reads back.
--
-- Nothing is shimmed: the startTowerBattle / setObjectSprite hooks and the
-- battleTower field all come from World itself.
--
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
-- POKEPORT_DRIVER=tests/drivers/gate_tower_noshim.lua love .
local U = require("tests.drivers.util")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
-- ---- the run --------------------------------------------------------------
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
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(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
ok(type(world.vm.specials.startTowerBattle) == "function",
"World:specialHooks supplies startTowerBattle, unshimmed")
ok(type(world.vm.specials.setObjectSprite) == "function",
"World:specialHooks supplies setObjectSprite, unshimmed")
local roster = BattleTower.roster(game.data)
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
if roster then
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
roster.sampleTrainers))
end
-- Three legal mons over the L10 room, each with ONE strong move so a blind
-- A-press run cannot pick a status move for a hundred turns.
local party = {}
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
{ "FERALIGATR", 60, "SURF", "BERRY" },
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
local def = game.data.moves[row[3]]
local mon = Mon.new(game.data, row[1], row[2], {
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
})
mon.item = row[4]
party[#party + 1] = mon
end
game.save.party = party
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
-- the L10 one (battle_tower.asm:1129-1141).
local tower = BattleTower.state(game.save)
tower.levelGroup = 1
tower.streak = 0
tower.trainers = {}
tower.challenge = BattleTower.NO_CHALLENGE
while game.stack:top() do game.stack:pop() end
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
U.wait(30)
shot("room-entered")
-- The scene script walks the player in, draws the opponent and starts the
-- battle; nothing here presses anything until a text box wants it.
local sawOpponent, sawBattle = false, false
for _ = 1, 900 do
local vm = world.vm
if vm and vm.btOpponent and not sawOpponent then
sawOpponent = true
say(("opponent: %s %s (row %d), sprite %s, group %d")
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
vm.btOpponent.group))
for slot, mon in ipairs(vm.btOpponent.rows) do
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
tostring(mon.item)))
end
U.wait(20)
shot("opponent-walked-in")
end
if world.battleActive then
sawBattle = true
break
end
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
end
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
local screen, battle = nil, nil
if sawBattle then
U.wait(90)
shot("battle-open")
screen = game.stack:top()
battle = screen and screen.battle
ok(battle ~= nil, "the battle screen owns a Battle")
if battle then
ok(battle.inBattleTowerBattle == true,
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
.. " L" .. tostring(battle.enemy and battle.enemy.level))
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
"with a three-mon Tower party")
end
end
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
-- move with PP left.
local function hardestMove(mon)
local best, bestPower = 1, -1
for index, move in ipairs((mon and mon.moves) or {}) do
local def = game.data.moves and game.data.moves[move.id]
local power = (def and def.power) or 0
if (move.pp or 0) > 0 and power > bestPower then
best, bestPower = index, power
end
end
return best
end
-- PP topped up between turns: an unattended run otherwise Struggles itself
-- to death long before the third opponent mon is down.
local function refill(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
move.pp = move.maxPp or move.pp
end
end
local menus = 0
for _ = 1, 900 do
if not world.battleActive or (battle and battle.over) then break end
local phase = screen and screen.phase
if phase == "menu" then
menus = menus + 1
if menus == 1 then shot("battle-menu") end
refill(battle and battle.player)
screen.menuIndex = 1
tap("a", 4)
elseif phase == "moves" then
screen.menuIndex = hardestMove(battle and battle.player)
tap("a", 4)
elseif phase == "submenu" then
-- A forced switch cannot be cancelled, so walk off the fainted lead.
tap("down", 3)
tap("a", 4)
else
tap("a", 3)
end
end
if battle then
say("battle over=" .. tostring(battle.over)
.. " outcome=" .. tostring(battle.outcome)
.. " phase=" .. tostring(screen and screen.phase))
ok(battle.over, "the battle resolved")
ok(battle.outcome == "win", "and the L60 party won it")
shot("battle-end")
end
for _ = 1, 200 do
if not world.battleActive then break end
tap("a", 3)
end
ok(not world.battleActive, "the battle screen came down")
U.wait(30)
shot("after-battle")
local vm = world.vm
local state = BattleTower.state(game.save)
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
tostring(vm and vm.stringBuffer)))
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
"and armed sBattleTowerChallengeState")
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
ok(state.prevTeams and #state.prevTeams.prev == 3,
"and sBTMonPrevTrainer recorded the team")
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
-- second draw all follow on their own.
local pages, secondDraw = {}, nil
for _ = 1, 400 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
if #pages <= 6 then shot("page-" .. #pages) end
end
if world.battleActive then
secondDraw = world.vm and world.vm.btOpponent
break
end
if world.choicebox then tap("a", 4) else tap("a", 3) end
end
say("pages: " .. table.concat(pages, " | "))
local joined = table.concat(pages, " | ")
ok(joined:find("healed to full health", 1, true) ~= nil
or joined:find("Next up", 1, true) ~= nil,
"the room script carried on past the battle")
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
or joined:find("no.2", 1, true) ~= nil,
"and wStringBuffer3 named the SECOND opponent")
if world.battleActive then
U.wait(60)
shot("second-battle")
ok(true, "the loop came back round for opponent two")
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
end
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end