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

556 lines
20 KiB
Lua

-- The Crystal story specials -- BeastsCheck, GiveDratini, GiveOddEgg and the
-- Ilex Forest shrine pair -- plus the roamer roster difference that makes the
-- Tin Tower Suicune catchable.
-- Self-contained: `luajit tests/gen2_crystal_story_test.lua`; also dofile'd by
-- tests/run_tests.lua. The cache half SKIPs when no crystal cache is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal story")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local Roamers = require("src.core.gen2.Roamers")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
local H = Specials.HANDLERS
local priorVersion = GameVersion.get()
local priorRandom = Specials.random
-- ---- fixtures -------------------------------------------------------------
-- pokecrystal/data/pokemon/base_stats/*.asm, the seven Odd Egg species plus
-- the Dragon Shrine's Dratini.
local BASE = {
PICHU = { 20, 40, 15, 60, 35, 35, "GROWTH_MEDIUM_FAST" },
CLEFFA = { 50, 25, 28, 15, 45, 55, "GROWTH_FAST" },
IGGLYBUFF = { 90, 30, 15, 15, 40, 20, "GROWTH_FAST" },
SMOOCHUM = { 45, 30, 15, 65, 85, 65, "GROWTH_MEDIUM_FAST" },
MAGBY = { 45, 75, 37, 83, 70, 55, "GROWTH_MEDIUM_FAST" },
ELEKID = { 45, 63, 37, 95, 65, 55, "GROWTH_MEDIUM_FAST" },
TYROGUE = { 35, 35, 35, 35, 35, 35, "GROWTH_MEDIUM_FAST" },
DRATINI = { 41, 64, 45, 50, 50, 50, "GROWTH_SLOW" },
RAIKOU = { 90, 85, 75, 115, 115, 100, "GROWTH_SLOW" },
ENTEI = { 115, 115, 85, 100, 90, 75, "GROWTH_SLOW" },
SUICUNE = { 100, 75, 115, 85, 90, 115, "GROWTH_SLOW" },
}
-- pokecrystal/data/moves/moves.asm, the PP byte of every move these two
-- routines hand out.
local MOVE_PP = {
THUNDERSHOCK = 30, CHARM = 20, DIZZY_PUNCH = 10, POUND = 35, SING = 15,
LICK = 30, EMBER = 25, QUICK_ATTACK = 30, LEER = 30, TACKLE = 35,
WRAP = 20, THUNDER_WAVE = 20, TWISTER = 20, EXTREMESPEED = 5,
}
-- pokecrystal/data/growth_rates.asm MEDIUM_FAST, FAST and SLOW.
local GROWTH = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
GROWTH_FAST = { numerator = 4, denominator = 5, squared = 0, linear = 0,
constant = 0 },
GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, linear = 0,
constant = 0 },
}
local INDEX = { PICHU = 172, CLEFFA = 173, IGGLYBUFF = 174, SMOOCHUM = 238,
MAGBY = 240, ELEKID = 239, TYROGUE = 236, DRATINI = 147, RAIKOU = 243,
ENTEI = 244, SUICUNE = 245 }
local DATA = { pokemon = { growthRates = GROWTH }, moves = {} }
for id, row in pairs(BASE) do
DATA.pokemon[id] = {
id = id, index = INDEX[id], name = id,
baseStats = { hp = row[1], attack = row[2], defense = row[3],
speed = row[4], specialAttack = row[5], specialDefense = row[6] },
types = { "NORMAL", "NORMAL" },
growthRate = row[7],
genderRatio = 127,
eggGroups = { "EGG_GROUND", "EGG_GROUND" },
eggSteps = 20,
evolutions = {},
levelMoves = { { level = 1, move = "TACKLE" } },
}
end
for id, pp in pairs(MOVE_PP) do DATA.moves[id] = { id = id, pp = pp } end
local ITEM_INDEX = { EGG_TICKET = 129, GS_BALL = 115 }
-- The World hook table a handler sees, with only the rows these five need.
local function makeVm(opts)
opts = opts or {}
local record = opts.save
local tossed = {}
local vars = opts.vars or {}
local vm = {
scriptVar = opts.scriptVar or 0,
battleOutcome = opts.battleOutcome,
tossed = tossed,
vars = vars,
readVarFn = function(id) return vars[id] or 0 end,
writeVarFn = function(id, value) vars[id] = value end,
specials = {
save = function() return record end,
data = function() return DATA end,
party = function() return (record and record.party) or {} end,
monIndex = function(species)
local def = DATA.pokemon[species]
return def and def.index or nil
end,
monName = function(index)
for id, def in pairs(DATA.pokemon) do
if def.index == index then return def.name end
end
return nil
end,
itemIndex = function(id) return ITEM_INDEX[id] end,
takeItem = function(index, qty)
tossed[#tossed + 1] = { index = index, qty = qty }
return true
end,
},
}
return vm
end
local function newSave()
return { version = "crystal", player = { name = "CHRIS", id = 30000 },
party = {}, boxes = {} }
end
-- ---- BeastsCheck ----------------------------------------------------------
-- pokecrystal/engine/pokemon/search_owned.asm:1
do
local record = newSave()
local function beast(species, opts)
opts = opts or {}
return { species = species, level = 40,
ot = opts.ot or "CHRIS", otId = opts.otId or 30000 }
end
local vm = makeVm({ save = record })
H.BeastsCheck(vm)
eq(vm.scriptVar, 0, "BeastsCheck is FALSE with an empty party")
record.party = { beast("RAIKOU"), beast("ENTEI") }
H.BeastsCheck(vm)
eq(vm.scriptVar, 0, "two of three is still FALSE")
-- CheckOwnMonAnywhere walks the PC after the party (:80 onward).
record.boxes = { box1 = { beast("SUICUNE") } }
H.BeastsCheck(vm)
eq(vm.scriptVar, 1, "the third one counts from a PC box")
record.boxes.box1[1].otId = 9999
H.BeastsCheck(vm)
eq(vm.scriptVar, 0, "a traded beast fails the trainer-ID check")
record.boxes.box1[1].otId = 30000
record.boxes.box1[1].ot = "ETHAN"
H.BeastsCheck(vm)
eq(vm.scriptVar, 0, "and a traded beast fails the OT-name check")
record.boxes.box1[1].ot = "CHRIS"
H.BeastsCheck(vm)
eq(vm.scriptVar, 1, "all three owned answers TRUE")
eq(vm.scriptVar, 1, "and re-running it is stable")
end
-- ---- GiveDratini ----------------------------------------------------------
-- pokecrystal/engine/events/dratini.asm:1
local function dratini(nickname)
local mon = Mon.new(DATA, "DRATINI", 15, {})
mon.nickname = nickname
mon.moves = {
{ id = "WRAP", pp = 20, maxPp = 20 },
{ id = "LEER", pp = 30, maxPp = 30 },
{ id = "THUNDER_WAVE", pp = 20, maxPp = 20 },
{ id = "TWISTER", pp = 20, maxPp = 20 },
}
return mon
end
local function moveIds(mon)
local out = {}
for _, move in ipairs(mon.moves or {}) do out[#out + 1] = move.id end
return table.concat(out, ",")
end
do
local record = newSave()
record.party = { { species = "CHIKORITA" }, dratini("FIRST"),
dratini("LAST") }
local vm = makeVm({ save = record, scriptVar = 0 })
H.GiveDratini(vm)
eq(moveIds(record.party[3]), "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED",
"scriptVar 0 gives .Moveset0, Extremespeed and all")
eq(moveIds(record.party[2]), "WRAP,LEER,THUNDER_WAVE,TWISTER",
"and only the LAST Dratini in the party is touched")
eq(record.party[3].moves[4].pp, 5,
"the new move's PP comes out of the move table")
eq(record.party[3].moves[4].maxPp, 5, "and so does its max PP")
local plain = newSave()
plain.party = { dratini("ONLY") }
local vm2 = makeVm({ save = plain, scriptVar = 1 })
H.GiveDratini(vm2)
eq(moveIds(plain.party[1]), "WRAP,LEER,THUNDER_WAVE,TWISTER",
"scriptVar 1 gives .Moveset1, the plain level 15 set")
local untouched = newSave()
untouched.party = { dratini("ONLY") }
local vm3 = makeVm({ save = untouched, scriptVar = 2 })
H.GiveDratini(vm3)
eq(moveIds(untouched.party[1]), "WRAP,LEER,THUNDER_WAVE,TWISTER",
"`cp $2 / ret nc` leaves the party alone")
local none = newSave()
none.party = { { species = "CHIKORITA", moves = {} } }
local vm4 = makeVm({ save = none, scriptVar = 0 })
H.GiveDratini(vm4)
eq(#none.party[1].moves, 0, "a party with no Dratini is left alone")
end
-- ---- GiveOddEgg -----------------------------------------------------------
-- pokecrystal/engine/events/odd_egg.asm:1
-- pokecrystal/data/events/odd_eggs.asm:14-33 run through the macro at :5:
-- cumulative percent * $ffff / 100.
local ODD_EGG_PROBABILITIES = { 5242, 5898, 16383, 18349, 28835, 30801, 39976,
41287, 47840, 49151, 57015, 58326, 64879, 65535 }
-- pokecrystal/data/events/odd_eggs.asm:37 OddEggs, row by row.
local ODD_EGG_ROWS = {
{ "PICHU", 2048, { 0, 0, 0, 0 }, { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
{ 17, 9, 6, 11, 8, 8 } },
{ "PICHU", 256, { 2, 10, 10, 10 }, { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
{ 17, 9, 7, 12, 9, 9 } },
{ "CLEFFA", 4096, { 0, 0, 0, 0 }, { "POUND", "CHARM", "DIZZY_PUNCH" },
{ 20, 7, 7, 6, 9, 10 } },
{ "CLEFFA", 768, { 2, 10, 10, 10 }, { "POUND", "CHARM", "DIZZY_PUNCH" },
{ 20, 7, 8, 7, 10, 11 } },
{ "IGGLYBUFF", 4096, { 0, 0, 0, 0 }, { "SING", "CHARM", "DIZZY_PUNCH" },
{ 24, 8, 6, 6, 9, 7 } },
{ "IGGLYBUFF", 768, { 2, 10, 10, 10 }, { "SING", "CHARM", "DIZZY_PUNCH" },
{ 24, 8, 7, 7, 10, 8 } },
{ "SMOOCHUM", 3584, { 0, 0, 0, 0 }, { "POUND", "LICK", "DIZZY_PUNCH" },
{ 19, 8, 6, 11, 13, 11 } },
{ "SMOOCHUM", 512, { 2, 10, 10, 10 }, { "POUND", "LICK", "DIZZY_PUNCH" },
{ 19, 8, 7, 12, 14, 12 } },
{ "MAGBY", 2560, { 0, 0, 0, 0 }, { "EMBER", "DIZZY_PUNCH" },
{ 19, 12, 8, 13, 12, 10 } },
{ "MAGBY", 512, { 2, 10, 10, 10 }, { "EMBER", "DIZZY_PUNCH" },
{ 19, 12, 9, 14, 13, 11 } },
{ "ELEKID", 3072, { 0, 0, 0, 0 }, { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
{ 19, 11, 8, 14, 11, 10 } },
{ "ELEKID", 512, { 2, 10, 10, 10 }, { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
{ 19, 11, 9, 15, 12, 11 } },
{ "TYROGUE", 2560, { 0, 0, 0, 0 }, { "TACKLE", "DIZZY_PUNCH" },
{ 18, 8, 8, 8, 8, 8 } },
{ "TYROGUE", 256, { 2, 10, 10, 10 }, { "TACKLE", "DIZZY_PUNCH" },
{ 18, 8, 9, 9, 9, 9 } },
}
-- Specials.random(n) is 1..n, so `roll` is the Random WORD the routine reads.
local function pinRoll(roll)
Specials.random = function(n)
if n == 0x10000 then return roll + 1 end
return priorRandom(n)
end
end
do
local wrong = {}
for index, row in ipairs(ODD_EGG_ROWS) do
local record = newSave()
-- The lowest roll that lands in this bucket: one past the previous
-- cumulative probability.
local roll = (index == 1) and 0 or (ODD_EGG_PROBABILITIES[index - 1] + 1)
pinRoll(roll)
local vm = makeVm({ save = record })
H.GiveOddEgg(vm)
local egg = record.party[1]
local label = "row " .. index .. " (" .. row[1] .. ")"
if not egg then
wrong[#wrong + 1] = label .. ": no egg"
else
if egg.species ~= row[1] then
wrong[#wrong + 1] = label .. ": species " .. tostring(egg.species)
end
if egg.otId ~= row[2] then
wrong[#wrong + 1] = label .. ": otId " .. tostring(egg.otId)
end
local dvs = egg.dvs or {}
if dvs.attack ~= row[3][1] or dvs.defense ~= row[3][2]
or dvs.speed ~= row[3][3] or dvs.special ~= row[3][4] then
wrong[#wrong + 1] = label .. ": DVs"
end
if moveIds(egg) ~= table.concat(row[4], ",") then
wrong[#wrong + 1] = label .. ": moves " .. moveIds(egg)
end
for slot, id in ipairs(row[4]) do
if egg.moves[slot].pp ~= MOVE_PP[id] then
wrong[#wrong + 1] = label .. ": pp " .. id
end
end
local stats = egg.stats or {}
local want = row[5]
if stats.hp ~= want[1] or stats.attack ~= want[2]
or stats.defense ~= want[3] or stats.speed ~= want[4]
or stats.specialAttack ~= want[5]
or stats.specialDefense ~= want[6] then
wrong[#wrong + 1] = label .. ": stats"
end
-- The struct's stat words are the Gen 2 formula's own output; if these
-- two ever disagree the transcription above is wrong, not the formula.
local computed = Mon.stats(DATA.pokemon[row[1]].baseStats, egg.dvs, 5, {})
if computed.hp ~= want[1] or computed.attack ~= want[2]
or computed.defense ~= want[3] or computed.speed ~= want[4]
or computed.specialAttack ~= want[5]
or computed.specialDefense ~= want[6] then
wrong[#wrong + 1] = label .. ": Mon.stats disagrees with the table"
end
end
end
eq(table.concat(wrong, " | "), "",
"all 14 OddEggs rows come out byte for byte")
-- The bucket boundary is inclusive: `cp e / jr z, .done`.
pinRoll(ODD_EGG_PROBABILITIES[1])
local edge = newSave()
H.GiveOddEgg(makeVm({ save = edge }))
eq(edge.party[1].otId, 2048, "a roll EQUAL to the probability stays in row 1")
pinRoll(ODD_EGG_PROBABILITIES[1] + 1)
local over = newSave()
H.GiveOddEgg(makeVm({ save = over }))
eq(over.party[1].otId, 256, "and one past it falls into row 2")
-- The last entry is $ffff, which is the loop's own break: every roll above
-- the 99% mark lands there.
pinRoll(0xffff)
local last = newSave()
H.GiveOddEgg(makeVm({ save = last }))
eq(last.party[1].species, "TYROGUE", "the $ffff row takes the top of the range")
eq(last.party[1].otId, 256, "and it is the shiny Tyrogue")
end
do
pinRoll(0)
local record = newSave()
local vm = makeVm({ save = record })
H.GiveOddEgg(vm)
local egg = record.party[1]
eq(egg.isEgg, true, "the Odd Egg arrives as an EGG")
eq(egg.nickname, "EGG", "wOddEggName is its nickname")
eq(egg.ot, "ODD", "and wTempOddEggNickname is its OT name")
eq(egg.otName, "ODD", "on both OT fields")
eq(egg.otId, 2048, "the OT ID is the struct's, not the player's")
eq(egg.eggSteps, 20, "20 step cycles to hatch")
eq(egg.hp, 0, "an egg is carried at zero HP")
eq(egg.level, 5, "at level 5")
eq(egg.experience, 125,
"and with the struct's 125 exp, not the curve's own value")
eq(#vm.tossed, 1, "the EGG TICKET is tossed")
eq(vm.tossed[1].index, 129, "by its item index")
eq(vm.tossed[1].qty, 1, "one of them")
-- CLEFFA is GROWTH_FAST, so 125 is more than level 5 costs: the copied
-- struct really does overshoot the curve.
pinRoll(ODD_EGG_PROBABILITIES[2] + 1)
local cleffa = newSave()
H.GiveOddEgg(makeVm({ save = cleffa }))
eq(cleffa.party[1].species, "CLEFFA", "the Cleffa bucket")
eq(cleffa.party[1].experience, 125, "carries 125 exp too")
eq(Mon.experienceForLevel(GROWTH.GROWTH_FAST, 5), 100,
"even though its own curve wants 100")
-- The second row of every pair is the classic shiny DV set.
pinRoll(ODD_EGG_PROBABILITIES[1] + 1)
local shiny = newSave()
H.GiveOddEgg(makeVm({ save = shiny }))
eq(shiny.party[1].shiny, true, "the 2/10/10/10 rows are shiny")
-- pokecrystal/maps/DayCare.asm:31-32 is the party-full guard, so a full
-- party must not grow a seventh slot here.
local full = newSave()
for i = 1, 6 do full.party[i] = { species = "CHIKORITA" } end
local vmFull = makeVm({ save = full })
H.GiveOddEgg(vmFull)
eq(#full.party, 6, "a full party gets no Odd Egg")
eq(#vmFull.tossed, 0, "and keeps its EGG TICKET")
end
Specials.random = priorRandom
-- ---- the Ilex Forest shrine -----------------------------------------------
-- pokecrystal/engine/events/celebi.asm:9 and :301
do
local record = newSave()
local vm = makeVm({ save = record })
H.CelebiShrineEvent(vm)
eq(vm.vars[0x03], 11,
"CelebiShrineEvent leaves BATTLETYPE_CELEBI in VAR_BATTLETYPE")
eq(vm.celebiArmed, true, "and arms the result bit for the battle to come")
vm.battleOutcome = "caught"
H.CheckCaughtCelebi(vm)
eq(vm.scriptVar, 1, "catching it in that battle answers TRUE")
eq(Save.crystalState(record).celebiCaught, true, "and is recorded")
eq(vm.celebiArmed, nil, "the arming is one battle long")
local ran = newSave()
local vm2 = makeVm({ save = ran, battleOutcome = "run" })
H.CelebiShrineEvent(vm2)
H.CheckCaughtCelebi(vm2)
eq(vm2.scriptVar, 0, "running from it answers FALSE")
eq(Save.crystalState(ran).celebiCaught, false, "and records nothing")
local won = newSave()
local vm3 = makeVm({ save = won, battleOutcome = "win" })
H.CelebiShrineEvent(vm3)
H.CheckCaughtCelebi(vm3)
eq(vm3.scriptVar, 0, "and knocking it out answers FALSE")
-- pokecrystal/engine/items/item_effects.asm:542 gates the bit on the battle
-- type, so an ordinary catch never sets it.
local other = newSave()
local vm4 = makeVm({ save = other, battleOutcome = "caught" })
H.CheckCaughtCelebi(vm4)
eq(vm4.scriptVar, 0, "a catch in a NORMAL battle does not count")
eq(Save.crystalState(other).celebiCaught, false, "and records nothing")
end
-- ---- the beasts that roam -------------------------------------------------
-- pokegold/data/wild/flee_mons.asm:34 against pokecrystal's:34
do
GameVersion.set("gold")
eq(Roamers.ALWAYS_FLEE.RAIKOU, true, "Gold: Raikou always flees")
eq(Roamers.ALWAYS_FLEE.ENTEI, true, "Gold: Entei always flees")
eq(Roamers.ALWAYS_FLEE.SUICUNE, true, "Gold: Suicune always flees")
GameVersion.set("silver")
eq(Roamers.ALWAYS_FLEE.SUICUNE, true, "Silver rides the same list")
GameVersion.set("crystal")
eq(Roamers.ALWAYS_FLEE.RAIKOU, true, "Crystal keeps Raikou on the list")
eq(Roamers.ALWAYS_FLEE.ENTEI, true, "and Entei")
eq(Roamers.ALWAYS_FLEE.SUICUNE, nil,
"but drops Suicune, so the Tin Tower battle can be fought")
eq(Roamers.ALWAYS_FLEE.PIDGEY, nil, "a mon on no list still never flees")
eq(Roamers.alwaysFleeMons("gold").SUICUNE, true,
"the accessor answers per version id")
eq(Roamers.alwaysFleeMons("crystal").SUICUNE, nil, "independently of the boot")
GameVersion.set(priorVersion)
end
-- Crystal seeds two roam structs, so the third slot the encounter roll can
-- pick is always empty -- pokecrystal/engine/overworld/wildmons.asm:493.
do
local twoRow = { roamMons = {
{ species = "RAIKOU", level = 40, map = "ROUTE_42" },
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
} }
local record = { version = "crystal" }
Roamers.init(record, { encounters = twoRow })
eq(#record.roamers, 2, "a Crystal cache seeds two roamers")
eq(record.roamers[1].species, "RAIKOU", "Raikou in slot 1")
eq(record.roamers[2].species, "ENTEI", "Entei in slot 2")
eq(record.roamers[3], nil, "and nothing in Suicune's old slot")
-- CheckEncounterRoamMon's `and %11 / jr z / dec a` picks slot 1, 2 or 3.
local hits = { 0, 0, 0 }
for value = 0, 99 do
local hit = Roamers.checkEncounter(record, "ROUTE_42", false,
function() return value end)
if hit then hits[hit.index] = hits[hit.index] + 1 end
end
check(hits[1] > 0, "slot 1 is still reachable on its own route")
eq(hits[3], 0, "slot 3 never produces an encounter on Crystal")
end
-- ------------------------------------------------------------ cache-gated
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
for _, dir in ipairs({ "crystal-dev", "lead-card" }) do
local candidate = home .. "/Library/Application Support/LOVE/" .. dir
.. "/crystal"
local probe = io.open(candidate .. "/data/generated/constants.lua", "r")
if probe then
probe:close()
cache = candidate
break
end
end
end
local constantsFile = cache and io.open(cache .. "/data/generated/constants.lua",
"r")
if not constantsFile then
check(true, "crystal cache absent : SKIP")
S.finish()
return
end
constantsFile:close()
local function loadLua(rel)
local chunk = loadfile(cache .. "/" .. rel)
if not chunk then return nil end
local ok, value = pcall(chunk)
return ok and value or nil
end
local constants = loadLua("data/generated/constants.lua")
local scripts = loadLua("data/generated/scripts.lua")
local encounters = loadLua("data/generated/encounters.lua")
check(constants ~= nil and scripts ~= nil, "the crystal cache loads")
-- Every handler above has to be a name the cache's own SpecialsPointers row
-- resolves to, and one an extracted script really calls.
local order = constants.specialOrder or {}
local calls = {}
for _, list in pairs(scripts) do
if type(list) == "table" then
for _, row in ipairs(list) do
if type(row) == "table" and row.op == "special" then
local name = order[(row.id or 0) + 1]
if name then calls[name] = (calls[name] or 0) + 1 end
end
end
end
end
for _, name in ipairs({ "BeastsCheck", "GiveDratini", "GiveOddEgg",
"CelebiShrineEvent", "CheckCaughtCelebi" }) do
check(Specials.HANDLERS[name] ~= nil, name .. " is a handler, not a stub")
check((calls[name] or 0) > 0,
name .. " is called by an extracted Crystal script")
end
-- pokecrystal/mobile/mobile_46.asm:6793: TradeCornerHoldMon lives in the
-- Mobile System GB bank and no map script reaches it.
eq(calls.TradeCornerHoldMon, nil,
"TradeCornerHoldMon is never called from a script")
check(Specials.STUBS.TradeCornerHoldMon ~= nil,
"so it stays a documented stub")
eq(#(encounters and encounters.roamMons or {}), 2,
"the Crystal cache carries two roamMons rows")
local roamNames = {}
for _, row in ipairs((encounters and encounters.roamMons) or {}) do
roamNames[#roamNames + 1] = row.species
end
eq(table.concat(roamNames, ","), "RAIKOU,ENTEI",
"Raikou and Entei, with Suicune off the roam list")
S.finish()