Files
gen1recomp/tests/gen2_crystal_vars_test.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

402 lines
15 KiB
Lua

-- The six .VarActionTable rows Crystal appends past VAR_SPECIALPHONECALL, and
-- TryQuickSave -- the save Link_SaveGame runs behind the Battle Tower desk.
-- ROM-free:
-- luajit tests/gen2_crystal_vars_test.lua
--
-- ../pokecrystal/constants/script_constants.asm:69-74,
-- ../pokecrystal/engine/overworld/variables.asm:62-67,
-- engine/link/link.asm:2356 and engine/menus/save.asm:63.
--
-- The point of the World half is that these reads and writes land in the SAVE.
-- World.scriptVars is rebuilt empty on every map load (src/world/gen2/World.lua
-- :579), so a var parked there is a var Buena's Blue Card loses the moment the
-- player walks out of the Radio Tower.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or {}
love.graphics = love.graphics or {
getColor = function() return 1, 1, 1, 1 end,
setColor = function() end,
rectangle = function() end,
print = function() end,
printf = function() end,
draw = function() end,
newQuad = function() return {} end,
newImage = function() return nil end,
getShader = function() return nil end,
setShader = function() end,
newShader = function() error("no shaders in this harness") end,
getDimensions = function() return 160, 144 end,
push = function() end, pop = function() end,
translate = function() end, scale = function() end,
circle = function() end, clear = function() end,
}
love.math = love.math or { random = function(a, b) return b and a or 1 end }
love.image = love.image or {}
love.filesystem = love.filesystem or {
load = function() return nil end,
getInfo = function() return nil end,
read = function() return nil end,
}
love.timer = love.timer or { getTime = function() return 0 end }
local S = require("tests.harness").suite("gen2 crystal vars")
local check, eq = S.check, S.eq
require("src.core.Logger").warn = function() end
local Apricorns = require("src.core.gen2.Apricorns")
local Events = require("src.world.gen2.Events")
local Save = require("src.core.gen2.Save")
local SaveSerializer = require("src.core.SaveSerializer")
local Specials = require("src.script.gen2.Specials")
local Vm = require("src.script.gen2.Vm")
local World = require("src.world.gen2.World")
local H = Specials.HANDLERS
-- ../pokecrystal/constants/script_constants.asm:69-74.
local VAR_BT_WIN_STREAK = 0x15
local VAR_KURT_APRICORNS = 0x16
local VAR_CALLERID = 0x17
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
local VAR_KENJI_BREAK = 0x1a
-- ---------------------------------------------------------------- fixtures
local DATA = {
audio = {},
items = {
LEVEL_BALL = { index = 5, name = "LEVEL BALL" },
RED_APRICORN = { index = 78, name = "RED APRICORN" },
BLU_APRICORN = { index = 79, name = "BLU APRICORN" },
},
pokemon = {},
moves = {},
}
local function newSave()
local save = {
version = "crystal",
player = { name = "KRIS", id = 0x1234, money = 0 },
party = {},
inventory = {},
bagOrder = {},
boxes = {},
}
Save.crystalState(save)
Save.battleTowerState(save)
return save
end
local function newWorld(save)
local game = { data = DATA, save = save, stack = nil }
local world = World.new(game)
world.vm = { curPhoneCaller = 0 }
return world, game
end
-- ============================================ the ids, against the cart's own
do
local save = newSave()
local world = newWorld(save)
-- .VarActionTable is walked by index, so a row in the wrong slot reads a
-- different variable. Pin the six by writing a distinct value into each
-- store and reading it back through the id the script byte carries.
Save.battleTowerState(save).streak = 4
save.kurtApricornQuantity = 3
world.vm.curPhoneCaller = 11
Save.crystalState(save).buenaPassword.balance = 17
Save.crystalState(save).buenaPassword.word = 0x52
Save.crystalState(save).kenjiBreak = 5
eq(world:readVar(VAR_BT_WIN_STREAK), 4, "$15 is wNrOfBeatenBattleTowerTrainers")
eq(world:readVar(VAR_KURT_APRICORNS), 3, "$16 is wKurtApricornQuantity")
eq(world:readVar(VAR_CALLERID), 11, "$17 is wCurCaller")
eq(world:readVar(VAR_BLUECARDBALANCE), 17, "$18 is wBlueCardBalance")
eq(world:readVar(VAR_BUENASPASSWORD), 0x52, "$19 is wBuenasPassword")
eq(world:readVar(VAR_KENJI_BREAK), 5, "$1a is wKenjiBreakTimer")
-- NUM_VARS' own guard: nothing above the table answers with a neighbour.
eq(world:readVar(0x1b), 0, "$1b is past the table and reads 0")
end
-- ================================================= the three writable rows
--
-- ../pokecrystal/engine/overworld/variables.asm:21-25: only a RETVAR_ADDR_DE
-- row hands `Script_writevar` the variable's own address. The other three
-- rows write wStringBuffer2, which nothing reads back.
do
local save = newSave()
local world = newWorld(save)
world:writeVar(VAR_BLUECARDBALANCE, 6)
eq(Save.crystalState(save).buenaPassword.balance, 6,
"writevar VAR_BLUECARDBALANCE lands in the save")
eq(world:readVar(VAR_BLUECARDBALANCE), 6, "and reads straight back")
world:writeVar(VAR_BUENASPASSWORD, 0x31)
eq(Save.crystalState(save).buenaPassword.word, 0x31,
"writevar VAR_BUENASPASSWORD lands in the save")
world:writeVar(VAR_CALLERID, 9)
eq(world.vm.curPhoneCaller, 9, "loadvar VAR_CALLERID parks wCurCaller")
eq(world:readVar(VAR_CALLERID), 9, "and the callee scripts read it back")
Save.battleTowerState(save).streak = 2
world:writeVar(VAR_BT_WIN_STREAK, 99)
eq(Save.battleTowerState(save).streak, 2,
"a RETVAR_STRBUF2 row is not writable")
eq(world:readVar(VAR_BT_WIN_STREAK), 2, "so the streak is unchanged")
-- One byte wide, `ld [de], a`.
world:writeVar(VAR_BLUECARDBALANCE, 300)
eq(world:readVar(VAR_BLUECARDBALANCE), 300 % 256, "the store is one byte")
end
-- =========================================== they survive a save and a reload
--
-- The whole bug: scriptVars is rebuilt empty at World.lua:579 and never
-- serialized, so a balance parked there is gone by the next map.
do
local save = newSave()
local world = newWorld(save)
world:writeVar(VAR_BLUECARDBALANCE, 12)
world:writeVar(VAR_BUENASPASSWORD, 0x24)
eq(world.scriptVars[VAR_BLUECARDBALANCE], 12,
"the scratch mirror still holds it in this session")
world.scriptVars = {}
eq(world:readVar(VAR_BLUECARDBALANCE), 12,
"and a map load that clears the mirror does not lose it")
local reloaded = SaveSerializer.decode(SaveSerializer.encode(save))
Save.normalize(reloaded)
local second = newWorld(reloaded)
eq(second:readVar(VAR_BLUECARDBALANCE), 12, "a reload still has the balance")
eq(second:readVar(VAR_BUENASPASSWORD), 0x24, "and the day's password")
end
-- ============================ ../pokecrystal/maps/RadioTower2F.asm:144-146
--
-- readvar VAR_BLUECARDBALANCE / addval 1 / writevar VAR_BLUECARDBALANCE
--
-- run through the VM against a real World, which is the round trip the gate
-- caught reading `blue card balance: 0`.
do
local save = newSave()
local world = newWorld(save)
local vm = Vm.new({}, {}, Events.new(), {
readVar = function(id) return world:readVar(id) end,
writeVar = function(id, value) world:writeVar(id, value) end,
})
local function awardPoint()
vm:start({
{ op = "readvar", var = VAR_BLUECARDBALANCE },
{ op = "addval", args = { 1 } },
{ op = "writevar", var = VAR_BLUECARDBALANCE },
})
local guard = 0
while vm:running() and guard < 64 do
vm:update()
guard = guard + 1
end
check(not vm:running(), "the three rows ran to the end")
end
awardPoint()
eq(world:readVar(VAR_BLUECARDBALANCE), 1, "a correct password is worth 1")
awardPoint()
awardPoint()
eq(Save.crystalState(save).buenaPassword.balance, 3,
"three shows is three points, in the save")
end
-- ======================================= Kurt's quantity, the other dead var
--
-- ../pokecrystal/maps/KurtsHouse.asm:197 is
-- `verbosegiveitemvar LEVEL_BALL, VAR_KURT_APRICORNS`, so a var that reads 0
-- hands over nothing at all.
do
local save = newSave()
save.inventory = { RED_APRICORN = 1 }
local world = newWorld(save)
local quantities = {}
local hooks = {
save = function() return save end,
data = function() return DATA end,
itemName = function(id) return (DATA.items[id] or {}).name or id end,
itemIndex = function(id) return (DATA.items[id] or {}).index end,
setKurtApricornQuantity = function(n)
quantities[#quantities + 1] = n
world:setKurtApricornQuantity(n)
end,
scriptMenu = function(_header, onChoose) onChoose(1) end,
}
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
vm.showTextFn = function() end
vm.co = coroutine.create(function() H.SelectApricornForKurt(vm) end)
local ok, err = coroutine.resume(vm.co)
check(ok, "SelectApricornForKurt ran: " .. tostring(err))
eq(quantities[1], 0, "kurt.asm:24 clears the byte on the way in")
eq(quantities[2], 1, "and kurt.asm:45 records the apricorn it tossed")
eq(world:readVar(VAR_KURT_APRICORNS), 1,
"so verbosegiveitemvar hands over one ball, not none")
eq(save.inventory.RED_APRICORN, nil, "the apricorn left the pack")
-- .GaveKurtApricorns' own setevent, which the map script runs, not this
-- handler: the count must not be conditioned on it.
check(Apricorns.pending(save) == nil, "the event is still the script's to set")
-- The cancel arm leaves the byte at zero, which is the `xor a` at :24.
local cancelled = {}
hooks.setKurtApricornQuantity = function(n) cancelled[#cancelled + 1] = n end
hooks.scriptMenu = function(header, onChoose) onChoose(#header.items) end
local vm2 = Vm.new({}, {}, Events.new(), { specials = hooks })
vm2.showTextFn = function() end
vm2.co = coroutine.create(function() H.SelectApricornForKurt(vm2) end)
coroutine.resume(vm2.co)
eq(#cancelled, 1, "a cancel writes the byte once")
eq(cancelled[1], 0, "and leaves it at zero")
end
-- ========================== SampleKenjiBreakCountdown, the KENJI_BREAK writer
--
-- ../pokecrystal/engine/overworld/time.asm:136: `Random / and %11 / add 3`.
do
eq(Specials.STUB_REASONS.SampleKenjiBreakCountdown, nil,
"SampleKenjiBreakCountdown is no longer a stub")
local save = newSave()
local world = newWorld(save)
local hooks = { setKenjiBreak = function(days) world:setKenjiBreak(days) end }
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
local seen = {}
local priorRandom = Specials.random
for roll = 1, 4 do
Specials.random = function() return roll end
H.SampleKenjiBreakCountdown(vm)
seen[#seen + 1] = world:readVar(VAR_KENJI_BREAK)
end
Specials.random = priorRandom
eq(table.concat(seen, ","), "3,4,5,6", "three to six days, inclusive")
eq(Save.crystalState(save).kenjiBreak, 6, "and the countdown is in the save")
end
-- ================================================================ TryQuickSave
--
-- engine/link/link.asm:2356 -> engine/menus/save.asm:63 Link_SaveGame.
-- ../pokecrystal/maps/BattleTower1F.asm:84-85 is `special TryQuickSave /
-- iffalse Script_Menu_ChallengeExplanationCancel`, so a FALSE here is a challenge that
-- never starts.
do
eq(Specials.STUB_REASONS.TryQuickSave, nil, "TryQuickSave is no longer a stub")
eq(Specials.HANDLER_SOURCE.TryQuickSave, "Specials.lua",
"and Specials.lua owns the handler")
end
local function runQuickSave(opts)
local save = newSave()
local log = { writes = 0, sfx = {} }
local hooks = {
save = function() return save end,
data = function() return DATA end,
saveFileState = function() return opts.exists, opts.sameId end,
writeSave = function()
log.writes = log.writes + 1
return opts.writeOk ~= false
end,
playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end,
}
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
vm.showTextFn = function() end
local pages, holds, asked = {}, {}, 0
local co = coroutine.create(function() H.TryQuickSave(vm) end)
vm.co = co
local send
while true do
local ok, req = coroutine.resume(co, send)
if not ok then error(req, 0) end
if coroutine.status(co) == "dead" then break end
send = nil
if type(req) == "table" and req.kind == "text" then
pages[#pages + 1] = req.text
holds[#holds + 1] = req.hold or 0
elseif type(req) == "table" and req.kind == "yesorno" then
asked = asked + 1
send = opts.answers and opts.answers[asked]
else
error("TryQuickSave parked on " .. tostring(req and req.kind), 0)
end
end
log.pages, log.holds, log.asked = pages, holds, asked
log.scriptVar = vm.scriptVar
return log
end
-- .yoursavefile: an existing file with this player's ID.
do
local log = runQuickSave({ exists = true, sameId = true, answers = { true } })
eq(log.asked, 1, "AskOverwriteSaveFile asks once")
check(log.pages[1]:find("already a", 1, true) ~= nil,
"AlreadyASaveFileText is the question: " .. tostring(log.pages[1]))
eq(log.writes, 1, "_SaveGameData ran")
eq(log.scriptVar, 1, "and wScriptVar is TRUE")
check(log.pages[2]:find("SAVING", 1, true) ~= nil, "SAVING page printed")
eq(log.holds[2], 16 + 32, "held for save.asm:247 + :251")
check(log.pages[3]:find("KRIS saved", 1, true) ~= nil,
"SavedTheGameText names the player: " .. tostring(log.pages[3]))
eq(log.holds[3], 30 + 30, "held for save.asm:269 + link.asm:2367")
eq(log.sfx[1], "Sfx_Save", "SFX_SAVE rang")
end
-- .refused: `jr nz, .refused / scf`, which TryQuickSave turns into FALSE.
do
local log = runQuickSave({ exists = true, sameId = true, answers = { false } })
eq(log.asked, 1, "the question was asked")
eq(log.writes, 0, "NO writes nothing")
eq(log.scriptVar, 0, "and wScriptVar is FALSE")
eq(#log.pages, 1, "no SAVING page follows a refusal")
end
-- The other ID: AnotherSaveFileText, and .erase rather than .ok.
do
local log = runQuickSave({ exists = true, sameId = false, answers = { true } })
check(log.pages[1]:find("another", 1, true) ~= nil,
"AnotherSaveFileText: " .. tostring(log.pages[1]))
eq(log.writes, 1, "and it still saves")
eq(log.scriptVar, 1, "TRUE")
end
-- `ld a, [wSaveFileExists] / and a / jr z, .erase`: no file, no question.
do
local log = runQuickSave({ exists = false, sameId = false })
eq(log.asked, 0, "a first save is not asked to overwrite anything")
eq(log.writes, 1, "it just writes")
eq(log.scriptVar, 1, "TRUE")
end
-- The one refusal the cart has no equivalent for: a mod vetoing save.write.
do
local log = runQuickSave({ exists = false, writeOk = false })
eq(log.writes, 1, "the write was attempted")
eq(log.scriptVar, 0, "a refused write is the same FALSE as a refused prompt")
end
-- ====================================== the World half of the two save hooks
do
local save = newSave()
local world, game = newWorld(save)
local wrote = 0
game.writeSave = function() wrote = wrote + 1 return true end
check(world:writeSave() == true, "World:writeSave goes through Game2:writeSave")
eq(wrote, 1, "exactly once")
game.writeSave = function() return false end
check(world:writeSave() == false, "and a vetoed write reports false")
game.writeSave = nil
check(world:writeSave() == false, "with no game there is nothing to write")
local hooks = world:specialHooks()
check(type(hooks.writeSave) == "function", "the writeSave hook is threaded")
check(type(hooks.saveFileState) == "function", "so is saveFileState")
end
S.finish()