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

315 lines
13 KiB
Lua

-- Crystal MON_CAUGHTDATA: the two packed bytes, who stamps them, and the two
-- readers that key off them (the level-up happiness pick and the fields that
-- have to survive an evolution and a Day-Care round trip).
-- luajit tests/gen2_crystal_caught_data_test.lua
--
-- Gold has no such word in its party struct, so every assertion below comes in
-- a Crystal half and a Gold half: Gold must come out with the three fields
-- still nil.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal caught data")
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 Catching = require("src.battle.gen2.Catching")
local Evolution = require("src.core.gen2.Evolution")
local Breeding = require("src.core.gen2.Breeding")
local Happiness = require("src.core.gen2.Happiness")
local Save = require("src.core.gen2.Save")
local priorVersion = GameVersion.get()
-- ---- fixtures -------------------------------------------------------------
-- GROWTH_MEDIUM_FAST is plain n^3 (data/growth_rates.asm).
local GROWTH = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
}
local function species(id, index, evolutions)
return {
id = id, index = index, name = id,
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
specialAttack = 65, specialDefense = 65 },
types = { "NORMAL", "NORMAL" },
growthRate = "GROWTH_MEDIUM_FAST",
genderRatio = 127,
eggGroups = { "EGG_GROUND", "EGG_GROUND" },
eggSteps = 20,
evolutions = evolutions or {},
levelMoves = { { level = 1, move = "TACKLE" } },
}
end
local DATA = {
moves = { TACKLE = { id = "TACKLE", pp = 35 } },
pokemon = {
growthRates = GROWTH,
CATERPIE = species("CATERPIE", 10,
{ { method = "EVOLVE_LEVEL", level = 7, into = "METAPOD" } }),
METAPOD = species("METAPOD", 11),
},
-- data/generated/landmarks.lua's shape, two rows deep enough for the
-- National Park override to resolve by name.
gen2Landmarks = {
landmarks = {
LANDMARK_NATIONAL_PARK = { id = "LANDMARK_NATIONAL_PARK", index = 19 },
LANDMARK_NEW_BARK_TOWN = { id = "LANDMARK_NEW_BARK_TOWN", index = 1 },
},
},
}
local function newMon(level, opts)
return Mon.new(DATA, "CATERPIE", level or 5, opts)
end
-- ---- the constants --------------------------------------------------------
eq(Mon.CAUGHT_TIME_MASK, 0xc0, "CAUGHT_TIME_MASK")
eq(Mon.CAUGHT_LEVEL_MASK, 0x3f, "CAUGHT_LEVEL_MASK")
eq(Mon.CAUGHT_GENDER_MASK, 0x80, "CAUGHT_GENDER_MASK")
eq(Mon.CAUGHT_LOCATION_MASK, 0x7f, "CAUGHT_LOCATION_MASK")
eq(Mon.CAUGHT_EGG_LEVEL, 1, "CAUGHT_EGG_LEVEL")
eq(Mon.LANDMARK_EVENT, 0x7f, "LANDMARK_EVENT")
eq(Mon.LANDMARK_GIFT, 0x7e, "LANDMARK_GIFT")
-- ---- the two bytes --------------------------------------------------------
-- `ld a, [wTimeOfDay] / inc a / rrca / rrca`: MORN 0 stores as 1, and 0 is
-- left free to mean unknown.
eq(Mon.caughtTimeOf(0), 1, "MORN stamps 1")
eq(Mon.caughtTimeOf(1), 2, "DAY stamps 2")
eq(Mon.caughtTimeOf(2), 3, "NITE stamps 3")
eq(Mon.caughtTimeOf("MORN"), 1, "the name reads the same as the id")
eq(Mon.caughtTimeOf(nil), 0, "and nothing at all is unknown")
eq(Mon.caughtTimeOf("BRUNCH"), 0, "as is a time of day that is not one")
local stamped = Mon.setCaughtData({ level = 12 },
{ timeOfDay = 2, landmark = 1, playerGender = "male" })
eq(stamped.caughtTime, 3, "NITE")
eq(stamped.caughtLevel, 12, "the level the catch happened at")
eq(stamped.caughtLocation, 1, "GetWorldMapLocation's landmark")
eq(stamped.caughtByGender, "boy", "and wPlayerGender bit 0 clear")
local byte0, byte1 = Mon.packCaughtData(stamped)
eq(byte0, 3 * 0x40 + 12, "byte 0 is ((tod + 1) << 6) | level")
eq(byte1, 1, "byte 1 is (female << 7) | landmark")
local female = Mon.setCaughtData({},
{ timeOfDay = 0, level = 7, landmark = 19, playerGender = "female" })
eq(female.caughtByGender, "girl", "PLAYERGENDER_FEMALE_F reads as girl")
byte0, byte1 = Mon.packCaughtData(female)
eq(byte0, 0x40 + 7, "MORN and level 7")
eq(byte1, 0x80 + 19, "the gender bit rides byte 1, not byte 0")
-- CAUGHT_LEVEL_MASK is six bits, so the packed level of a high-level catch
-- wraps exactly as `or b` leaves it on the cart.
byte0 = Mon.packCaughtData(Mon.setCaughtData({},
{ timeOfDay = 1, level = 70, landmark = 1 }))
eq(byte0 % 0x40, 70 % 0x40, "level 70 packs into six bits")
local round = Mon.unpackCaughtData(Mon.packCaughtData(female))
eq(round.caughtTime, 1, "unpack recovers the time")
eq(round.caughtLevel, 7, "the level")
eq(round.caughtLocation, 19, "the location")
eq(round.caughtByGender, "girl", "and the gender bit")
local blank = Mon.unpackCaughtData(0, 0)
eq(blank.caughtTime, 0, "an all-zero word is an unknown time")
eq(blank.caughtLevel, 0, "an unknown level")
eq(blank.caughtLocation, 0, "and an unknown location")
-- SetGiftMonCaughtData folds CAUGHT_BY_* through `rrc b`, which is why
-- CAUGHT_BY_BOY lands on LANDMARK_EVENT instead of on a gender bit.
local gift = Mon.setGiftCaughtData({}, "girl")
eq(gift.caughtTime, 0, "a gift mon has no caught time")
eq(gift.caughtLevel, 0, "and no caught level")
eq(gift.caughtLocation, Mon.LANDMARK_GIFT, "LANDMARK_GIFT")
eq(gift.caughtByGender, "girl", "with the gender bit set")
eq(Mon.setGiftCaughtData({}, "boy").caughtLocation, Mon.LANDMARK_EVENT,
"CAUGHT_BY_BOY rotates into bit 0 and makes it LANDMARK_EVENT")
eq(Mon.setGiftCaughtData({}, "unknown").caughtLocation, Mon.LANDMARK_GIFT,
"CAUGHT_BY_UNKNOWN leaves LANDMARK_GIFT alone")
-- ---- who stamps -----------------------------------------------------------
GameVersion.set("gold")
check(not Mon.hasCaughtData(), "Gold's struct has no MON_CAUGHTDATA")
local goldMon = newMon(5)
eq(goldMon.caughtLevel, 5, "Gold still gets the caught level it always had")
eq(goldMon.caughtTime, nil, "but no caught time")
eq(goldMon.caughtLocation, nil, "no caught location")
eq(goldMon.caughtByGender, nil, "and no OT gender")
Catching.stampCaughtData(goldMon,
{ data = DATA, timeOfDay = 1, map = { id = "ROUTE_29", landmark = 1 } })
eq(goldMon.caughtTime, nil, "and SetCaughtData is a no-op on Gold")
GameVersion.set("crystal")
check(Mon.hasCaughtData(), "the crystal lineage carries it")
local caught = newMon(4)
Catching.stampCaughtData(caught, {
data = DATA, timeOfDay = 1, playerGender = "female",
map = { id = "ROUTE_29", landmark = 1 },
})
eq(caught.caughtTime, 2, "a Crystal catch stamps the time")
eq(caught.caughtLevel, 4, "the level")
eq(caught.caughtLocation, 1, "the map's landmark")
eq(caught.caughtByGender, "girl", "and the player's gender")
-- The Pokecenter 2F substitution: wBackupMapGroup / wBackupMapNumber, so the
-- cable-club floor never lands on LANDMARK_SPECIAL.
eq(Catching.caughtLandmark({
map = { id = "POKECENTER_2F", landmark = 0 },
backupMap = { id = "VIOLET_CITY", landmark = 6 },
}), 6, "POKECENTER_2F reads the map it was entered from")
eq(Catching.caughtLandmark({ map = { id = "POKECENTER_2F", landmark = 0 } }), 0,
"with no backup map recorded it stays unknown")
eq(Catching.caughtLandmark({ map = { id = "ROUTE_29", landmark = 1 } }), 1,
"every other map is a plain GetWorldMapLocation")
eq(Catching.caughtLandmark({}), 0, "and no map at all is unknown")
-- BugContest_SetCaughtContestMon overwrites the location afterwards, keeping
-- the gender bit.
eq(Catching.caughtLandmark({ data = DATA, bugContest = true,
map = { id = "NATIONAL_PARK_BUG_CONTEST", landmark = 0 } }), 19,
"the Bug Contest catch is LANDMARK_NATIONAL_PARK")
eq(Catching.caughtLandmark({ bugContest = true }),
Catching.LANDMARK_NATIONAL_PARK,
"and the same index without a landmarks table to look it up in")
local contest = Catching.stampCaughtData(newMon(6), {
data = DATA, bugContest = true, timeOfDay = 1, playerGender = "female",
map = { id = "NATIONAL_PARK_BUG_CONTEST", landmark = 0 },
})
eq(contest.caughtLocation, 19, "the contest mon is stamped National Park")
eq(contest.caughtByGender, "girl", "and keeps CAUGHT_GENDER_MASK")
-- ---- survival across an evolution -----------------------------------------
check(Evolution.MON_FIELDS.caughtTime, "MON_FIELDS names caughtTime")
check(Evolution.MON_FIELDS.caughtLocation, "and caughtLocation")
check(Evolution.MON_FIELDS.caughtByGender, "and caughtByGender")
local evolved = Evolution.apply(DATA, caught,
{ method = "EVOLVE_LEVEL", level = 7, into = "METAPOD" })
eq(evolved.species, "METAPOD", "it evolved")
eq(evolved.caughtTime, 2, "the caught time survived")
eq(evolved.caughtLevel, 4, "the caught level survived")
eq(evolved.caughtLocation, 1, "the caught location survived")
eq(evolved.caughtByGender, "girl", "and so did the OT gender")
-- ---- survival across the Day-Care, and the egg marker ---------------------
local function newSave(party)
local save = { version = "crystal", party = party or {},
player = { name = "CHRIS", id = 1, gender = "female", money = 5000 },
pokedex = { seen = {}, caught = {} } }
return save
end
local deposited = newMon(5)
Catching.stampCaughtData(deposited, {
data = DATA, timeOfDay = 2, playerGender = "female",
map = { id = "ROUTE_34", landmark = 3 },
})
local save = newSave({})
local dc = Breeding.dayCare(save)
dc.man.mon = deposited
dc.man.level = 5
local ok, withdrawn = Breeding.withdraw(DATA, save, "man")
check(ok ~= false, "the mon came back out of the Day-Care")
withdrawn = save.party[1]
eq(withdrawn.caughtTime, 3, "RetrieveBreedmon carried the caught time")
eq(withdrawn.caughtLevel, 5, "the caught level")
eq(withdrawn.caughtLocation, 3, "the caught location")
eq(withdrawn.caughtByGender, "girl", "and the OT gender")
local egg = newMon(5)
egg.isEgg = true
egg.eggSteps = 0
egg.level = 5
save = newSave({ egg })
local hatched = Breeding.hatch(DATA, save, 1, nil,
{ timeOfDay = 0, landmark = 3 })
check(hatched ~= nil, "the egg hatched")
eq(hatched.caughtLevel, Mon.CAUGHT_EGG_LEVEL,
"SetEggMonCaughtData stamps CAUGHT_EGG_LEVEL, not the hatch level")
eq(hatched.caughtTime, 1, "with the hatch site's time of day")
eq(hatched.caughtLocation, 3, "and its landmark")
eq(hatched.caughtByGender, "girl", "off save.player.gender")
eq(Mon.packCaughtData(hatched) % 0x40, Mon.CAUGHT_EGG_LEVEL,
"so byte 0's level field reads 1")
GameVersion.set("gold")
local goldEgg = newMon(5)
goldEgg.isEgg = true
goldEgg.eggSteps = 0
local goldSave = newSave({ goldEgg })
goldSave.version = "gold"
goldSave.player.gender = nil
local goldHatch = Breeding.hatch(DATA, goldSave, 1)
eq(goldHatch.caughtLevel, 5, "a Gold hatchling keeps the egg's own level")
eq(goldHatch.caughtTime, nil, "and grows no caught-data keys")
eq(goldHatch.caughtLocation, nil, "none of them")
eq(goldHatch.caughtByGender, nil, "at all")
GameVersion.set("crystal")
-- ---- HAPPINESS_GAINLEVELATHOME --------------------------------------------
eq(Happiness.EVENT.GAINLEVELATHOME, 19, "the enum's 19th row")
eq(Happiness.NUM_EVENTS, 19, "and Crystal's table is one longer than Gold's")
eq(#Happiness.CHANGES, 19, "every row is present")
eq(Happiness.delta("GAINLEVELATHOME", 0), 10, "+10 under 100")
eq(Happiness.delta("GAINLEVELATHOME", 100), 6, "+6 under 200")
eq(Happiness.delta("GAINLEVELATHOME", 200), 4, "+4 above it")
check(Happiness.delta("GAINLEVELATHOME", 0) > Happiness.delta("GAINLEVEL", 0),
"and it is worth more than a plain level")
local home = { happiness = 70, caughtLocation = 3 }
eq(Happiness.levelUpEvent(home, 3), "GAINLEVELATHOME",
"levelling where it was caught")
eq(Happiness.levelUpEvent(home, 4), "GAINLEVEL", "and anywhere else")
eq(Happiness.levelUpEvent({ caughtLocation = 3 }, nil), "GAINLEVEL",
"an unknown current landmark cannot match")
eq(Happiness.levelUpEvent({}, 0), "GAINLEVEL",
"and a mon with no caught data at all never reads ATHOME")
eq(Happiness.levelUpEvent({ caughtLocation = 0 }, 0), "GAINLEVELATHOME",
"though a Crystal mon stamped LANDMARK_SPECIAL does, as the cart's cp does")
-- CAUGHT_LOCATION_MASK: the comparison is against the low seven bits, so the
-- gender bit riding the same byte cannot break the match.
eq(Happiness.levelUpEvent({ caughtLocation = 0x80 + 3 }, 3), "GAINLEVELATHOME",
"the gender bit is masked out of the comparison")
eq(Happiness.levelUp(home, 3), 80, "levelUp applies the +10")
eq(Happiness.levelUp(home, 9), 85, "and the +5 away from home")
-- ---- the save layer -------------------------------------------------------
eq(Save.defaultPlayerName("gold"), "GOLD", "PlayerNameArray's first row")
eq(Save.defaultPlayerName("silver"), "SILVER", "on Silver")
eq(Save.defaultPlayerName("crystal"), "CHRIS", "and MalePlayerNameArray's")
local normalized = Save.normalize({ version = "crystal", player = {} })
eq(normalized.player.gender, "male", "normalize defaults wPlayerGender to 0")
eq(Save.normalize({ version = "crystal",
player = { gender = "female" } }).player.gender, "female",
"and leaves a recorded gender alone")
-- A Gold save that never catches anything grows no new keys.
GameVersion.set("gold")
local goldParty = { newMon(5) }
local goldFile = Save.normalize({ version = "gold", party = goldParty })
eq(goldFile.party[1].caughtTime, nil, "no caughtTime on a Gold record")
eq(goldFile.party[1].caughtLocation, nil, "no caughtLocation")
eq(goldFile.party[1].caughtByGender, nil, "no caughtByGender")
GameVersion.set(priorVersion)
S.finish()