mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +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.
402 lines
15 KiB
Lua
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()
|