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.
This commit is contained in:
bryanthaboi
2026-08-23 12:10:28 -04:00
parent e88f2ef060
commit ca4d3d283c
141 changed files with 43306 additions and 583 deletions
@@ -41,7 +41,7 @@ end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
or v == "silver" or v == "crystal"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
@@ -57,6 +57,14 @@ RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("crystal")
check(#capturedShortcuts == 4, "nor does a sixth")
check(capturedShortcuts[1] == "crystal", "activeVersion 'crystal' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"and the rest still follow GameVersion.ORDER until the cap")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
return v == "red" or v == "gold"
+218
View File
@@ -0,0 +1,218 @@
-- Crystal registration: VERSIONS row, engine lineage, ORDER slot, sha1
-- routing, the importer's required-file override and the script dialect.
-- luajit tests/engine/crystal_version_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("crystal version registration")
local check = S.check
local eq = S.eq
local GameVersion = require("src.core.GameVersion")
local Opcodes = require("src.script.gen2.Opcodes")
-- ------- 1. the VERSIONS row
local row = GameVersion.VERSIONS.crystal
check(row ~= nil, "GameVersion.VERSIONS carries a crystal row")
eq(row.id, "crystal", "row id")
eq(row.label, "Crystal", "row label")
eq(row.displayName, "Pokemon Crystal", "row display name")
eq(row.sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "retail Crystal sha1")
eq(row.manifest, "tools/rom_manifest_crystal.json", "row manifest path")
eq(row.cachePrefix, "crystal/", "cache prefix")
eq(row.saveSuffix, "_crystal", "save suffix")
eq(GameVersion.cachePrefix("crystal"), "crystal/", "cachePrefix() agrees")
eq(GameVersion.saveSuffix("crystal"), "_crystal", "saveSuffix() agrees")
local prefixes, suffixes = {}, {}
for _, id in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(id)
eq(prefixes[info.cachePrefix], nil, id .. " cache prefix is unique")
eq(suffixes[info.saveSuffix], nil, id .. " save suffix is unique")
prefixes[info.cachePrefix] = id
suffixes[info.saveSuffix] = id
end
-- ------- 2. generation and engine lineage
eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
eq(GameVersion.engine("crystal"), "crystal", "Crystal's engine lineage")
eq(GameVersion.engine("gold"), "gs", "Gold is the gs lineage")
eq(GameVersion.engine("silver"), "gs", "and so is Silver")
eq(GameVersion.engine("red"), "gen1", "Red is gen1")
eq(GameVersion.engine("blue"), "gen1", "Blue is gen1")
eq(GameVersion.engine("yellow"), "gen1", "Yellow is gen1")
local LINEAGES = { gen1 = true, gs = true, crystal = true }
for _, id in ipairs(GameVersion.ORDER) do
check(LINEAGES[GameVersion.engine(id)] == true,
id .. " reports a known engine lineage")
end
eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
eq(GameVersion.generation("red"), 1, "Red is Gen 1")
-- ------- 3. launcher ORDER
local index
for i, id in ipairs(GameVersion.ORDER) do
if id == "crystal" then index = i end
end
eq(index, 6, "crystal is ORDER slot 6")
eq(#GameVersion.ORDER, 6, "ORDER is six games")
eq(GameVersion.ORDER[4], "gold", "gold keeps slot 4")
eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5")
-- ------- 4. sha1 routing
eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal",
"the retail Crystal sha1 resolves to crystal")
eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold",
"Gold's sha1 still resolves to gold")
eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing")
-- ------- 5. set / get round trip
local savedCurrent = GameVersion.get()
eq(GameVersion.set("crystal"), "crystal", "set('crystal') is accepted")
eq(GameVersion.get(), "crystal", "and becomes current")
eq(GameVersion.engine(), "crystal", "engine() with no argument reads current")
check(not GameVersion.isGold(), "isGold() stays false on Crystal")
GameVersion.set(savedCurrent)
-- ------- 6. the importer's required-file list
local RomImporter = require("src.import.RomImporter")
love.filesystem = love.filesystem or {}
local savedGetInfo = love.filesystem.getInfo
local savedGetReal = love.filesystem.getRealDirectory
local savedGetSource = love.filesystem.getSource
local function probe(version)
local seen, order = {}, {}
love.filesystem.getInfo = function(name)
if not seen[name] then
seen[name] = true
order[#order + 1] = name
end
return { type = "file" }
end
love.filesystem.getRealDirectory = function() return "/nowhere" end
love.filesystem.getSource = function() return "/elsewhere" end
RomImporter.isReady(version)
return seen, order
end
local crystalSeen, crystalOrder = probe("crystal")
local redSeen = probe("red")
love.filesystem.getInfo = savedGetInfo
love.filesystem.getRealDirectory = savedGetReal
love.filesystem.getSource = savedGetSource
check(#crystalOrder > 20,
("the crystal list is substantial (%d entries probed)"):format(#crystalOrder))
for _, path in ipairs({
"crystal/data/generated/encounters.lua",
"crystal/data/generated/landmarks.lua",
"crystal/data/generated/title.lua",
"crystal/data/generated/intro.lua",
"crystal/assets/generated/title/crystal_logo.png",
"crystal/assets/generated/title/crystal_wordmark.png",
"crystal/assets/generated/title/crystal_suicune.png",
"crystal/assets/generated/splash/ditto.png",
"crystal/assets/generated/intro/chris.png",
"crystal/assets/generated/intro/kris.png",
"crystal/assets/generated/battle/front/wooper.png",
}) do
check(crystalSeen[path] == true, "crystal requires " .. path)
end
for _, path in ipairs({
"crystal/assets/generated/battle/anims/move_anim_0.png",
"crystal/assets/generated/battle/anims/move_anim_1.png",
"crystal/data/generated/battle_anims.lua",
"crystal/assets/generated/trade/game_boy.png",
}) do
eq(crystalSeen[path], nil, "crystal does not wait on " .. path)
end
check(redSeen["assets/generated/battle/anims/move_anim_0.png"] == true,
"red still requires the Gen 1 battle anim sheet")
eq(redSeen["assets/generated/title/crystal_logo.png"], nil,
"and none of Crystal's title art")
-- ------- 7. script dialect
local crystalTable = Opcodes.forEdition("crystal")
local goldTable = Opcodes.forEdition("gold")
check(crystalTable ~= goldTable,
"forEdition('crystal') is not the Gold table")
eq(Opcodes.forEdition("silver"), goldTable, "Silver shares Gold's table")
eq(goldTable, Opcodes, "and Gold's table is the module itself")
eq(Opcodes.forEdition(nil), Opcodes, "an absent edition falls back to Gold")
-- pokecrystal/macros/scripts/events.asm:541
eq(crystalTable[0x52] and crystalTable[0x52].name, "farjumptext",
"Crystal $52 is farjumptext")
eq(goldTable[0x52] and goldTable[0x52].name, "jumptext",
"Gold $52 is jumptext")
eq(crystalTable[0x53] and crystalTable[0x53].name, "jumptext",
"and Crystal's jumptext moved to $53")
local function count(tbl)
local n = 0
for key in pairs(tbl) do if type(key) == "number" then n = n + 1 end end
return n
end
eq(count(crystalTable), 170, "Crystal names 170 commands")
eq(count(goldTable), 162, "Gold names 162")
local diverged
for byte = 0x00, 0x51 do
local a = crystalTable[byte] and crystalTable[byte].name
local b = goldTable[byte] and goldTable[byte].name
if a ~= b then diverged = byte; break end
end
eq(diverged, nil, "$00-$51 are the same commands in both dialects")
check(Opcodes.TERMINATORS.farjumptext == true,
"farjumptext is a terminator")
-- ------- 8. the launcher reaches the crystal tab
local visited = {}
local fake = setmetatable({ tab = GameVersion.ORDER[1] }, RomImporter)
fake._switchTab = function(self, id)
self.tab = id
visited[#visited + 1] = id
end
for _ = 1, 12 do fake:_cycleTab(1) end
local sawCrystal, sawMods, sawBug = false, false, false
for _, id in ipairs(visited) do
if id == "crystal" then sawCrystal = true end
if id == "mods" then sawMods = true end
if id == "bug" then sawBug = true end
end
check(sawCrystal, "cycling the launcher tabs reaches crystal")
check(sawMods and sawBug, "and still reaches the mods and bug tabs")
local seen, cycle = {}, 0
fake.tab = "crystal"
repeat
seen[fake.tab] = true
fake:_cycleTab(1)
cycle = cycle + 1
until fake.tab == "crystal" or cycle > 40
eq(cycle, #GameVersion.ORDER + 4,
"the ring is the six games plus mods/find/skins/bug")
fake.tab = "crystal"
fake:_cycleTab(-1)
eq(fake.tab, "silver", "and stepping back off crystal lands on silver")
S.finish()
+1
View File
@@ -24,6 +24,7 @@ T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
T.eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
+292
View File
@@ -0,0 +1,292 @@
-- 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()
+4 -1
View File
@@ -39,7 +39,7 @@ end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
for _, version in ipairs({ "gold", "silver" }) do
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local model = LauncherSettings.open(hooks, version)
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(model, "VIBRATION"), version .. " and VIBRATION")
@@ -82,6 +82,7 @@ for _, version in ipairs({ "gold", "silver" }) do
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
eq(model.opts.crystal, nil, version .. " nor a third")
local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value()
@@ -114,6 +115,8 @@ eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "crystal"), "TOUCH CONTROLS"), false,
"nor on Crystal")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
+15 -3
View File
@@ -90,11 +90,23 @@ check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
check(imp:find("_installMod", 1, true) ~= nil,
"and a zip elsewhere still installs a mod")
local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
local RomImporter = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local cycled, probe = {}, nil
probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter })
probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end
for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end
local reached = " " .. table.concat(cycled, " ") .. " "
check(reached:find(" skins ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
check(reached:find(" bug ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the bug tab")
for _, id in ipairs(GameVersion.ORDER) do
if id ~= GameVersion.ORDER[1] then
check(reached:find(" " .. id .. " ", 1, true) ~= nil,
"shoulder-button tab cycling reaches " .. id)
end
end
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list")
+20 -12
View File
@@ -27,18 +27,25 @@ end
-- ------- tokens expand off GameVersion, never a literal list
-- "nonesuch" is the deliberate never-a-game token for every unknown-version
-- case below. A real version id was used here twice ("gold", then "crystal")
-- and both had to be swapped the day that game shipped; this one never will.
local NO_SUCH_GAME = "nonesuch"
do
eq(table.concat(ModTargets.expand("red"), ","), "red",
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver,crystal",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("crystal"), ","), "crystal",
"Crystal included, the day its VERSIONS row landed")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
@@ -48,9 +55,9 @@ do
eq(table.concat(versions, ","), "red,gold",
"normalize dedupes and sorts into GameVersion.ORDER")
eq(#unknown, 0, "known tokens leave nothing unreported")
local _, bad = ModTargets.normalize({ "crystal", "gen1" })
local _, bad = ModTargets.normalize({ NO_SUCH_GAME, "gen1" })
eq(#bad, 1, "an unknown token comes back for the caller to report")
eq(bad[1], "crystal", "by name")
eq(bad[1], NO_SUCH_GAME, "by name")
end
-- ------- the legacy reading: gen2compat only ever ADDS Gen 2
@@ -58,7 +65,7 @@ end
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver,crystal",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -69,23 +76,23 @@ end
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(list(gen2), "gold,silver,crystal", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
eq(list(both), "red,blue,yellow,gold,silver,crystal", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver,crystal",
"an old gen2compat beside a new games list still adds its game")
end
do
-- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like
-- every other manifest vocabulary (Manifest.violation)
local lenient = mf({ games = { "crystal", "red" } })
local lenient = mf({ games = { NO_SUCH_GAME, "red" } })
eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest")
check(not pcall(mf, { api = 2, games = { "crystal" } }),
check(not pcall(mf, { api = 2, games = { NO_SUCH_GAME } }),
"api 2 refuses a game it does not have")
check(not pcall(mf, { games = "gen1" }),
"games must be an array, not a bare string")
@@ -226,12 +233,13 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, crystal = { a = true },
gold = { a = true }, silver = { a = true },
[NO_SUCH_GAME] = { a = true },
red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion[NO_SUCH_GAME], nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
@@ -57,9 +57,10 @@ end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
for _, ver in ipairs(GameVersion.ORDER) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
+2 -2
View File
@@ -82,7 +82,7 @@ local function importer(ready)
end
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
silver = true, crystal = true })
allReady:_queueBaseRomScan()
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
eq(listings, 0, "ready launcher does not enumerate baseroms")
@@ -125,7 +125,7 @@ missing:choose("red")
eq(picks, 1, "the next import attempt falls back to the native picker")
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
silver = true, crystal = true })
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
rescanned:reimport("red")
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")