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
+396 -2
View File
@@ -1,4 +1,4 @@
-- Headless Gold save-editor rules. Run from repo root:
-- Headless Gen 2 save-editor rules. Run from repo root:
-- luajit tests/save_editor_gen2_tests.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
@@ -90,6 +90,7 @@ do
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
eq(Gen.of({ version = "silver" }), 2, "Gen.of version silver")
eq(Gen.of({ version = "crystal" }), 2, "Gen.of version crystal")
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
end
@@ -116,7 +117,7 @@ do
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
end
for _, version in ipairs({ "gold", "silver" }) do
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local S = newState(version)
Ops.partyAdd(S)
eq(#S.save.party, 1, "partyAdd on " .. version)
@@ -424,5 +425,398 @@ do
GameVersion.set("red")
end
do
GameVersion.set("red")
local crystal = Gen.newGame("crystal")
eq(crystal.version, "crystal", "Gen.newGame('crystal') stamps crystal")
eq(crystal.generation, 2, "crystal stub is generation 2")
eq(crystal.player.name, "CHRIS", "crystal stub uses Crystal's preset name")
eq(Gen.newGame("silver").version, "silver", "Gen.newGame('silver') stamps silver")
eq(Gen.newGame("silver").player.name, "SILVER", "silver stub keeps SILVER")
eq(Gen.newGame("gold").player.name, "GOLD", "gold stub keeps GOLD")
eq(Gen.newGame("blue").version, "blue", "Gen.newGame('blue') stamps blue")
eq(Gen.newGame("yellow").version, "yellow", "Gen.newGame('yellow') stamps yellow")
eq(Gen.newGame(nil).version, "red", "no version falls back to the active game")
eq(Gen.newGame("nonsense").version, "red", "an unknown id is ignored")
end
do
local crystal = Gen.newGame("crystal")
local gold = Gen.newGame("gold")
eq(Gen.versionOf(crystal), "crystal", "versionOf reads the save")
eq(Gen.engineOf(crystal), "crystal", "crystal saves are the crystal lineage")
eq(Gen.engineOf(gold), "gs", "gold saves are the gs lineage")
eq(Gen.engineOf(Gen.newGame("silver")), "gs", "silver saves are the gs lineage")
eq(Gen.editionLabel(crystal), "CRYSTAL", "edition label follows the save")
eq(Gen.editionLabel(gold), "GOLD", "gold still labels GOLD")
check(Gen.hasCaughtData(crystal), "crystal has caught data")
check(not Gen.hasCaughtData(gold), "gold has no caught data")
check(not Gen.hasCaughtData(SaveData.newGame()), "gen 1 has no caught data")
check(Gen.hasPlayerGender(crystal), "crystal has a player gender")
check(not Gen.hasPlayerGender(gold), "gold has no player gender")
end
do
local Gen2Flags = require("Gen2Flags")
local gs = Gen2Flags.byName("gs")
local crys = Gen2Flags.byName("crystal")
eq(gs.EVENT_BEAT_FALKNER, crys.EVENT_BEAT_FALKNER,
"a shared event keeps one id in both lineages")
check(gs.EVENT_BEAT_FALKNER ~= nil, "the gs table is populated")
eq(gs.EVENT_GOT_RAINBOW_WING, 120, "Gold's EVENT_GOT_RAINBOW_WING")
eq(crys.EVENT_GOT_RAINBOW_WING, 822, "Crystal renumbers EVENT_GOT_RAINBOW_WING")
eq(gs.EVENT_TIN_TOWER_1F_SUICUNE, nil, "a Crystal-only event is absent on gs")
eq(crys.EVENT_TIN_TOWER_1F_SUICUNE, 1970, "and resolves on crystal")
eq(crys.EVENT_WADE_READY_FOR_REMATCH, nil, "a retired Gold event is absent on crystal")
check(gs.EVENT_WADE_READY_FOR_REMATCH ~= nil, "and still resolves on gs")
check(Gen2Flags.byName("crystal") == crys, "byName caches per lineage")
check(Gen2Flags.byName("gold") == gs, "an unknown lineage reads as gs")
end
do
local crystalNames = Catalog.gen2EventList("crystal")
local goldNames = Catalog.goldEventList()
local function has(list, name)
for _, n in ipairs(list) do if n == name then return true end end
return false
end
check(has(crystalNames, "EVENT_TIN_TOWER_1F_SUICUNE"),
"the crystal event list names Crystal's own flags")
check(not has(crystalNames, "EVENT_WADE_READY_FOR_REMATCH"),
"the crystal event list drops Gold's retired flags")
check(has(goldNames, "EVENT_WADE_READY_FOR_REMATCH"),
"the gold event list keeps them")
check(has(goldNames, "EVENT_BEAT_FALKNER"), "gold event list still has Falkner")
check(#crystalNames > #goldNames, "Crystal has more event flags than Gold")
end
do
local S = newState("crystal")
local name = "EVENT_TIN_TOWER_1F_SUICUNE"
Ops.setFlag(S, name, true)
check(Gen.getFlag(S.save, name), "a Crystal-only event reads back set")
eq(S.save.flags[name], nil, "and never lands as a string key")
check(next(S.save.events) ~= nil, "it wrote the numeric bitfield")
local G = newState("gold")
Ops.setFlag(G, name, true)
eq(G.save.flags[name], true, "the same name on Gold has no id and stays named")
local moved = newState("crystal")
Ops.setFlag(moved, "EVENT_GOT_RAINBOW_WING", true)
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(moved.save.events)
check(ev:get(822), "EVENT_GOT_RAINBOW_WING sets Crystal's bit 822")
check(not ev:get(120), "not Gold's bit 120")
end
do
local S = newState("crystal")
Ops.partyAdd(S)
local mon = S.save.party[1]
check(Ops.setCaughtTime(S, mon, 2), "caught time set")
eq(mon.caughtTime, 2, "caught time DAY")
check(S.dirty, "caught time dirties the save")
check(not Ops.setCaughtTime(S, mon, 2), "re-setting the same time only says")
Ops.setCaughtTime(S, mon, 9)
eq(mon.caughtTime, 3, "caught time clamps to NITE")
Ops.setCaughtTime(S, mon, -1)
eq(mon.caughtTime, 0, "caught time clamps to unknown")
Ops.setCaughtLevel(S, mon, 40)
eq(mon.caughtLevel, 40, "caught level 40")
Ops.setCaughtLevel(S, mon, 99)
eq(mon.caughtLevel, 63, "caught level clamps to CAUGHT_LEVEL_MASK")
Ops.setCaughtLocation(S, mon, 12)
eq(mon.caughtLocation, 12, "caught location 12")
Ops.setCaughtLocation(S, mon, -1)
eq(mon.caughtLocation, 127, "stepping below 0 wraps to LANDMARK_EVENT")
Ops.setCaughtLocation(S, mon, 128)
eq(mon.caughtLocation, 0, "and stepping past the mask wraps back to unknown")
Ops.setCaughtByGender(S, mon, "girl")
eq(mon.caughtByGender, "girl", "caught by girl")
Ops.setCaughtByGender(S, mon, "none")
eq(mon.caughtByGender, nil, "caught by clears to nil, the Gold-save value")
local Mon = require("src.battle.gen2.Mon")
Ops.setCaughtTime(S, mon, 3)
Ops.setCaughtLevel(S, mon, 20)
Ops.setCaughtLocation(S, mon, 5)
Ops.setCaughtByGender(S, mon, "girl")
local byte0, byte1 = Mon.packCaughtData(mon)
eq(byte0, 3 * 0x40 + 20, "the editor's fields pack into byte 0")
eq(byte1, 0x80 + 5, "and into byte 1")
end
do
local G = newState("gold")
Ops.partyAdd(G)
local mon = G.save.party[1]
G.dirty = false
check(not Ops.setCaughtTime(G, mon, 2), "Gold refuses caught time")
eq(mon.caughtTime, nil, "and writes nothing")
check(not Ops.setCaughtLevel(G, mon, 20), "Gold refuses caught level")
check(not Ops.setCaughtLocation(G, mon, 5), "Gold refuses caught location")
check(not Ops.setCaughtByGender(G, mon, "boy"), "Gold refuses caught gender")
check(not G.dirty, "a refused caught edit never dirties")
end
do
local S = newState("crystal")
eq(Gen.playerGender(S.save), "male", "a new Crystal save starts male")
check(Ops.setPlayerGender(S, "female"), "player gender set")
eq(S.save.player.gender, "female", "written to save.player.gender")
check(not Ops.setPlayerGender(S, "female"), "re-setting the same gender only says")
Ops.setPlayerGender(S, "male")
eq(Gen.playerGender(S.save), "male", "and back")
local G = newState("gold")
check(not Ops.setPlayerGender(G, "female"), "Gold refuses a gender change")
eq(G.save.player.gender, "male", "Gold's field is left alone")
end
do
local landmarks = {
gen2Landmarks = {
landmarks = {
LANDMARK_AZALEA_TOWN = { id = "LANDMARK_AZALEA_TOWN", index = 12,
name = "AZALEA TOWN" },
LANDMARK_BURNED_TOWER = { id = "LANDMARK_BURNED_TOWER", index = 24,
name = "BURNED\nTOWER" },
},
},
}
eq(Gen.landmarkName(landmarks, 12), "AZALEA TOWN", "landmark by index")
eq(Gen.landmarkName(landmarks, 24), "BURNED TOWER", "two-line names flatten")
eq(Gen.landmarkName(landmarks, 0), "UNKNOWN", "0 reads as unknown")
eq(Gen.landmarkName(landmarks, 0x7e), "GIFT", "LANDMARK_GIFT")
eq(Gen.landmarkName(landmarks, 0x7f), "EVENT", "LANDMARK_EVENT")
eq(Gen.landmarkName(landmarks, 99), "#99", "an unmapped index shows its number")
end
do
local Kit = require("Kit")
local MonEditor = require("MonEditor")
local ItemsPanel = require("Items")
local function clipped(r)
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
if r.clip then
x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y)
x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h)
end
if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end
return x1, y1, x2, y2
end
local function overlap(a, b)
local ax1, ay1, ax2, ay2 = clipped(a)
if not ax1 then return false end
local bx1, by1, bx2, by2 = clipped(b)
if not bx1 then return false end
return math.min(ax2, bx2) - math.max(ax1, bx1) > 1
and math.min(ay2, by2) - math.max(ay1, by1) > 1
end
local function auditFrame(label, W, H)
local controls = {}
for _, r in ipairs(Kit.audit or {}) do
if r.class == "control" then controls[#controls + 1] = r end
end
check(#controls > 0, label .. ": the frame dispatched controls at all")
local collisions, escapes = 0, 0
for i = 1, #controls do
local a = controls[i]
local x1, y1, x2, y2 = clipped(a)
if x1 and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then
escapes = escapes + 1
print((" escape: %s (%.0f,%.0f %.0fx%.0f)")
:format(a.label, a.x, a.y, a.w, a.h))
end
for j = i + 1, #controls do
if overlap(a, controls[j]) then
collisions = collisions + 1
print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)")
:format(a.label, controls[j].label, a.x, a.y,
controls[j].x, controls[j].y))
end
end
end
check(collisions == 0, label .. ": no two controls overlap")
check(escapes == 0, label .. ": every control stays inside the panel")
end
local sizes = { { 420, 700 }, { 640, 768 }, { 1280, 720 } }
for _, version in ipairs({ "gold", "crystal" }) do
for _, size in ipairs(sizes) do
local W, H = size[1], size[2]
local S = newState(version)
Ops.partyAdd(S)
Ops.selectParty(S, 1)
Ops.addToBag(S, S.cat.items[1])
Ops.addToPc(S, S.cat.items[2])
Kit.layout(W, H)
for _, panel in ipairs({ { "inspector", MonEditor }, { "items", ItemsPanel } }) do
local label = ("%s %dx%d %s"):format(version, W, H, panel[1])
Kit.beginFrame(-100, -100, false, 0)
Kit.audit = {}
local ok, err = pcall(panel[2].draw, S, Kit, 0, 0, W, H)
check(ok, label .. " draws: " .. tostring(err))
if ok then
Kit.beginFrame(-100, -100, false, 0)
Kit.audit = {}
ok, err = pcall(panel[2].draw, S, Kit, 0, 0, W, H)
check(ok, label .. " redraws: " .. tostring(err))
end
if ok then auditFrame(label, W, H) end
Kit.audit = nil
end
end
end
local S = newState("crystal")
Ops.partyAdd(S)
Ops.selectParty(S, 1)
Kit.layout(1280, 720)
Kit.beginFrame(-100, -100, false, 0)
Kit.audit = {}
MonEditor.draw(S, Kit, 0, 0, 1280, 720)
local labels = {}
for _, r in ipairs(Kit.audit) do labels[r.label] = true end
Kit.audit = nil
check(labels.MORN and labels.NITE, "the Crystal inspector offers the caught times")
check(labels.BOY and labels.GIRL, "and the OT gender chips")
local G = newState("gold")
Ops.partyAdd(G)
Ops.selectParty(G, 1)
Kit.beginFrame(-100, -100, false, 0)
Kit.audit = {}
MonEditor.draw(G, Kit, 0, 0, 1280, 720)
local goldLabels = {}
for _, r in ipairs(Kit.audit) do goldLabels[r.label] = true end
Kit.audit = nil
check(not goldLabels.MORN, "the Gold inspector has no caught row")
local function itemsLabels(state)
Kit.beginFrame(-100, -100, false, 0)
Kit.audit = {}
ItemsPanel.draw(state, Kit, 0, 0, 1280, 720)
local seen = {}
for _, r in ipairs(Kit.audit) do seen[r.label] = true end
Kit.audit = nil
return seen
end
check(itemsLabels(S).GIRL, "the Crystal Items tab carries the TRAINER card")
check(not itemsLabels(G).GIRL, "the Gold Items tab does not")
local function bottomOverflow(state)
local H = 380
Kit.layout(640, H)
state.inspectorScroll = 100000
for _ = 1, 2 do
Kit.beginFrame(-100, -100, false, 0)
Kit.audit = {}
MonEditor.draw(state, Kit, 0, 0, 640, H)
end
local lowest = 0
for _, r in ipairs(Kit.audit) do
if r.class == "control" then lowest = math.max(lowest, r.y + r.h) end
end
Kit.audit = nil
return math.floor(lowest - H + 0.5)
end
local goldOver = bottomOverflow(G)
eq(bottomOverflow(S), goldOver,
"the caught rows are fully counted in the inspector's scroll height")
check(goldOver <= 4, ("the inspector reaches its last control (%d px short)")
:format(goldOver))
end
local function readable(path)
local fh = io.open(path, "r")
if not fh then return false end
fh:close()
return true
end
do
local Gen2Flags = require("Gen2Flags")
local goldAsm = (os.getenv("POKEGOLD") or "../pokegold")
.. "/constants/event_flags.asm"
local crystalAsm = (os.getenv("POKECRYSTAL") or "../pokecrystal")
.. "/constants/event_flags.asm"
if readable(goldAsm) and readable(crystalAsm) and readable("tools/goldwalk/flags.lua") then
local Flags = dofile("tools/goldwalk/flags.lua")
local gold = Flags.parse(goldAsm)
local crys = Flags.parse(crystalAsm)
local addedBad, removedBad, missing = 0, 0, 0
for name, id in pairs(crys) do
if gold[name] ~= id and Gen2Flags.CRYSTAL_ADDED[name] ~= id then
missing = missing + 1
end
end
for name, id in pairs(Gen2Flags.CRYSTAL_ADDED) do
if crys[name] ~= id then addedBad = addedBad + 1 end
end
for name in pairs(Gen2Flags.CRYSTAL_REMOVED) do
if crys[name] ~= nil or gold[name] == nil then removedBad = removedBad + 1 end
end
eq(missing, 0, "every Crystal event Gold disagrees on is in CRYSTAL_ADDED")
eq(addedBad, 0, "every CRYSTAL_ADDED id matches pokecrystal")
eq(removedBad, 0, "every CRYSTAL_REMOVED name is Gold-only")
local byName = Gen2Flags.byName("crystal")
local wrong = 0
for name, id in pairs(crys) do
if byName[name] ~= id then wrong = wrong + 1 end
end
eq(wrong, 0, "the resolved crystal table is pokecrystal's whole event list")
else
check(true, "pokegold / pokecrystal absent : flag delta cross-check SKIPPED")
end
end
do
local Gen2Flags = require("Gen2Flags")
local cache = os.getenv("CRYSTAL_CACHE")
or ((os.getenv("HOME") or "") ..
"/Library/Application Support/LOVE/lead-crystal/crystal")
local initialPath = cache .. "/data/generated/initial_events.lua"
local stdScripts = (os.getenv("POKECRYSTAL") or "../pokecrystal")
.. "/engine/events/std_scripts.asm"
if readable(initialPath) and readable(stdScripts) then
local Flags = dofile("tools/goldwalk/flags.lua")
local romIds = assert(loadfile(initialPath))().flags
-- InitializeEventsScript sets EVENT_AZALEA_TOWN_KURT twice in a row
-- (../pokecrystal/engine/events/std_scripts.asm:575-576).
local names = {}
for _, n in ipairs(Flags.setEventsOf(stdScripts, "InitializeEventsScript")) do
if names[#names] ~= n then names[#names + 1] = n end
end
local byName = Gen2Flags.byName("crystal")
eq(#names, #romIds, "InitializeEventsScript sets as many flags as the cart")
local mismatches, lo, hi = 0, math.huge, -math.huge
for i, name in ipairs(names) do
local got = romIds[i]
if got then lo, hi = math.min(lo, got), math.max(hi, got) end
if byName[name] ~= got then
mismatches = mismatches + 1
if mismatches <= 5 then
print((" #%d %s table=%s cart=%s")
:format(i, name, tostring(byName[name]), tostring(got)))
end
end
end
eq(mismatches, 0,
("crystal names == cart ids across %d..%d"):format(lo, hi))
else
check(true, "crystal cache absent : cart cross-check SKIPPED")
end
end
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end