mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
ca4d3d283c
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.
279 lines
10 KiB
Lua
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
|