Files
gen1recomp/tests/drivers/crystal_story_shots.lua
T
bryanthaboi ca4d3d283c 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.
2026-08-23 12:10:28 -04:00

279 lines
10 KiB
Lua

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