mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user