Files
gen1recomp/tests/engine/gen2_script_opcodes_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

293 lines
14 KiB
Lua

-- Gen 2 script bytecode dialects: Gold/Silver vs Crystal.
--
-- Crystal inserts farjumptext at $52 and pushes every later opcode up by one
-- (pokecrystal/macros/scripts/events.asm:541). Decoding a Crystal script with
-- the Gold table is silent: a Crystal $53 `jumptext` (2 operand bytes) reads as
-- Gold's `waitbutton` (0), the pointer walk desynchronises, and the extractor
-- emits plausible garbage rather than an error. So the expected tables below
-- are transcribed from the two macro files by hand and pinned here.
-- luajit tests/engine/gen2_script_opcodes_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local Opcodes = require("src.script.gen2.Opcodes")
-- pokegold/macros/scripts/events.asm:1-1015, in const order from $00.
-- Sizes are the macro body minus its own `db <name>_command`: db 1, dw 2,
-- dba 3, bigdt 3, map_id 2 (pokegold/macros/scripts/maps.asm:1-6).
-- `givepoke` is the one variable-length row: the macro emits 8 bytes when the
-- trainer argument is non-zero (events.asm:352-365) and the table declares the
-- 4-byte base, which src/import/RomExtractorGen2.lua re-measures per call site.
local GOLD_EXPECTED = {
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
"closewindow 0", "jumptextfaceplayer 2", "jumptext 2", "waitbutton 0",
"promptbutton 0", "pokepic 1", "closepokepic 0", "_2dmenu 0",
"verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", "swarm 2",
"halloffame 0", "credits 0", "warpfacing 5",
}
-- pokecrystal/macros/scripts/events.asm:1-1068, same transcription rules.
-- Cross-checked row for row against ScriptCommandTable
-- (pokecrystal/engine/overworld/scripting.asm:64-237), which is the table the
-- hardware actually indexes and therefore the authority over the macro file.
local CRYSTAL_EXPECTED = {
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
"closewindow 0", "jumptextfaceplayer 2", "farjumptext 3", "jumptext 2",
"waitbutton 0", "promptbutton 0", "pokepic 1", "closepokepic 0",
"_2dmenu 0", "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2",
"verbosegiveitemvar 2", "swarm 3", "halloffame 0", "credits 0",
"warpfacing 5", "battletowertext 1", "getlandmarkname 2",
"gettrainerclassname 2", "getname 3", "wait 1", "checksave 0",
}
local gold = Opcodes.forEdition("gold")
local crystal = Opcodes.forEdition("crystal")
-- CT-1: gold and silver share one dialect, and Opcodes[byte] keeps answering.
check(Opcodes.forEdition("silver") == gold, "silver resolves to the Gold table")
check(gold == Opcodes, "the Gold table is the module itself (Opcodes[byte])")
check(crystal ~= gold, "crystal resolves to a different table")
check(Opcodes.forEdition(nil) == gold, "an unknown edition falls back to Gold")
eq(Opcodes[0x52] and Opcodes[0x52].name, "jumptext",
"Opcodes[0x52] is unchanged for existing callers")
local function auditTable(label, tbl, expected)
local holes, wrong = {}, {}
for i, want in ipairs(expected) do
local byte = i - 1
local name, size = want:match("^(%S+) (%d+)$")
local row = tbl[byte]
if not row then
holes[#holes + 1] = ("$%02x"):format(byte)
elseif row.name ~= name or row.size ~= tonumber(size) then
wrong[#wrong + 1] = ("$%02x %s/%d wanted %s/%s")
:format(byte, tostring(row.name), row.size or -1, name, size)
end
end
eq(#holes, 0, label .. " has no missing opcode (" .. table.concat(holes, " ")
.. ")")
eq(#wrong, 0, label .. " matches events.asm (" .. table.concat(wrong, "; ")
.. ")")
local extra = {}
for byte = 0x00, 0xff do
if tbl[byte] and byte >= #expected then
extra[#extra + 1] = ("$%02x %s"):format(byte, tbl[byte].name)
end
end
eq(#extra, 0, label .. " declares nothing past the last command ("
.. table.concat(extra, " ") .. ")")
end
auditTable("gold", gold, GOLD_EXPECTED)
auditTable("crystal", crystal, CRYSTAL_EXPECTED)
-- (a) $00-$51 is byte-identical between the dialects.
local drift = {}
for byte = 0x00, 0x51 do
local g, c = gold[byte], crystal[byte]
if not (g and c and g.name == c.name and g.size == c.size) then
drift[#drift + 1] = ("$%02x"):format(byte)
end
end
eq(#drift, 0, "$00-$51 is identical in both dialects ("
.. table.concat(drift, " ") .. ")")
-- and the two dialects agree on NOTHING from $52 up, because the whole tail is
-- shifted by one. farjumptext is the wedge.
eq(crystal[0x52].name, "farjumptext", "$52 is farjumptext on Crystal")
eq(crystal[0x52].size, 3, "farjumptext carries a dba, so 3 operand bytes")
eq(crystal[0x53].name, "jumptext", "Gold's $52 jumptext moved to $53")
eq(crystal[0xa0].size, 3,
"Crystal swarm gained a leading flag byte (events.asm:1003-1008)")
eq(gold[0x9e].size, 2, "Gold swarm is still a bare map_id")
-- (c) NUM_EVENT_COMMANDS, both as the declared constant and as the row count.
local function rowCount(tbl)
local n = 0
for byte = 0x00, 0xff do if tbl[byte] then n = n + 1 end end
return n
end
eq(Opcodes.NUM_EVENT_COMMANDS, 162,
"pokegold events.asm:1015 NUM_EVENT_COMMANDS = $a2")
eq(crystal.NUM_EVENT_COMMANDS, 170,
"pokecrystal events.asm:1068 NUM_EVENT_COMMANDS = $aa")
eq(rowCount(gold), Opcodes.NUM_EVENT_COMMANDS,
"the Gold table is dense up to NUM_EVENT_COMMANDS")
eq(rowCount(crystal), crystal.NUM_EVENT_COMMANDS,
"the Crystal table is dense up to NUM_EVENT_COMMANDS")
-- (d) farjumptext ends the walk the same way jumptext does.
check(Opcodes.TERMINATORS.farjumptext,
"farjumptext is a TERMINATOR (jp ScriptJump, scripting.asm:318-327)")
check(Opcodes.TERMINATORS.jumptext, "jumptext still is")
check(crystal.TERMINATORS == Opcodes.TERMINATORS,
"the Crystal table answers TERMINATORS too")
eq(crystal.key, Opcodes.key, "and key(), so a resolved table is self-sufficient")
-- (e) MOD_COMMAND is reachable only by name: no byte in EITHER dialect decodes
-- to it, so ROM data can never be mistaken for a mod verb.
local collide = {}
for byte = 0x00, 0xff do
if gold[byte] and gold[byte].name == Opcodes.MOD_COMMAND then
collide[#collide + 1] = ("gold $%02x"):format(byte)
end
if crystal[byte] and crystal[byte].name == Opcodes.MOD_COMMAND then
collide[#collide + 1] = ("crystal $%02x"):format(byte)
end
end
eq(#collide, 0, "MOD_COMMAND has no byte in either dialect ("
.. table.concat(collide, " ") .. ")")
check(type(Opcodes.MOD_COMMAND) == "string" and Opcodes.MOD_COMMAND ~= "",
"MOD_COMMAND is a name, not a byte")
-- The Vm side of the dialect: every Crystal-only verb has a branch, and the
-- shifted `swarm` reads its flag rather than its map group.
love = require("tests.love_stub")
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
do
local seen, swarmArgs = {}, nil
local events = Events.new()
local vm = Vm.new({
generation = 2,
["s:crystal"] = {
-- pokecrystal/macros/scripts/events.asm:1003-1008: flag, then map_id.
{ op = "swarm", args = { 1, 24, 3 } },
{ op = "checksave" },
{ op = "wait", args = { 2 } },
{ op = "getlandmarkname", args = { 5, 3 } },
{ op = "gettrainerclassname", args = { 9, 3 } },
{ op = "getname", args = { 1, 152, 3 } },
{ op = "verbosegiveitemvar", args = { 20, 7 } },
{ op = "farjumptext", text = "t:far" },
{ op = "setevent", event = 1 },
},
}, { ["t:far"] = "Far text." }, events, {
setSwarm = function(group, mapNum, kind)
swarmArgs = { group, mapNum, kind }
end,
checkSave = function() return true end,
getLandmarkName = function(id) seen.landmark = id return "RUINS" end,
getTrainerClassName = function(id) seen.class = id return "SAGE" end,
getMonName = function(id) seen.mon = id return "CHIKORITA" end,
readVar = function(id) seen.var = id return 4 end,
giveItem = function(item, qty) seen.give = { item, qty } return true end,
getItemName = function() return "REPEL" end,
showText = function(body, onDone) seen.text = body onDone() end,
})
check(vm:start("s:crystal"), "a Crystal-shaped script starts")
for _ = 1, 40 do vm:update() end
check(not vm:running(), "and runs to completion")
eq(swarmArgs and swarmArgs[1], 24, "swarm reads the map group from args[2]")
eq(swarmArgs and swarmArgs[2], 3, "and the map number from args[3]")
eq(swarmArgs and swarmArgs[3], 1, "and passes SWARM_YANMA through as the kind")
eq(seen.landmark, 5, "getlandmarkname passes its landmark id to the hook")
eq(seen.class, 9, "gettrainerclassname passes the trainer group")
eq(seen.mon, 152, "getname with MON_NAME routes to the mon-name hook")
eq(seen.var, 7, "verbosegiveitemvar reads the quantity out of a var")
eq(seen.give and seen.give[1], 20, "and gives the item the first byte names")
eq(seen.give and seen.give[2], 4, "at the quantity the var held")
eq(seen.text, "Far text.", "farjumptext prints its text")
eq(next(vm.unknownOps or {}), nil,
"no Crystal verb in the script fell through to the unknown ledger")
-- Script_farjumptext ends on `jp ScriptJump`, so the setevent after it never
-- runs -- the same reading Opcodes.TERMINATORS encodes for the extractor.
check(not events:get(1),
"farjumptext ended the script before the setevent below it")
events:set(1, true)
check(events:get(1), "and the event really is observable when it is set")
end
do
-- Script_wait is SIX frames per unit (scripting.asm:2336-2347), not
-- Script_pause's two.
local vm = Vm.new({ generation = 2,
["s:wait"] = { { op = "wait", args = { 3 } }, { op = "end" } },
}, {}, nil, {})
check(vm:start("s:wait"), "a lone `wait` starts")
local frames = 0
while vm:running() and frames < 100 do
vm:update()
frames = frames + 1
end
eq(frames, 18, "`wait 3` holds the script for 3 * 6 frames")
end
T.finish()