mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
ca4d3d283c
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.
823 lines
30 KiB
Lua
823 lines
30 KiB
Lua
-- 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"
|
|
|
|
local love_stub = require("tests.love_stub")
|
|
love = love_stub
|
|
|
|
local passed, failed = 0, 0
|
|
|
|
local function check(cond, msg)
|
|
if cond then
|
|
passed = passed + 1
|
|
else
|
|
failed = failed + 1
|
|
print("FAIL: " .. msg)
|
|
end
|
|
end
|
|
|
|
local function eq(a, b, msg)
|
|
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
|
|
end
|
|
|
|
print("== save editor gen2 tests ==")
|
|
|
|
local Gen = require("Gen")
|
|
local Catalog = require("Catalog")
|
|
local MonOps = require("MonOps")
|
|
local Ops = require("Ops")
|
|
local State = require("State")
|
|
local Save2 = require("src.core.gen2.Save")
|
|
local SaveData = require("src.core.SaveData")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
|
|
local data = {
|
|
pokemon = {
|
|
CYNDAQUIL = {
|
|
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
|
|
types = { "FIRE" },
|
|
baseStats = {
|
|
hp = 39, attack = 52, defense = 43, speed = 65,
|
|
specialAttack = 60, specialDefense = 50,
|
|
},
|
|
catchRate = 45, baseExp = 65,
|
|
growthRate = "MEDIUM_FAST",
|
|
levelMoves = { { level = 1, move = "TACKLE" } },
|
|
genderRatio = 31,
|
|
},
|
|
TOTODILE = {
|
|
id = "TOTODILE", name = "TOTODILE", dex = 158,
|
|
types = { "WATER" },
|
|
baseStats = {
|
|
hp = 50, attack = 65, defense = 64, speed = 43,
|
|
specialAttack = 44, specialDefense = 48,
|
|
},
|
|
catchRate = 45, baseExp = 66,
|
|
growthRate = "MEDIUM_FAST",
|
|
levelMoves = { { level = 1, move = "SCRATCH" } },
|
|
genderRatio = 31,
|
|
},
|
|
},
|
|
moves = {
|
|
TACKLE = { pp = 35 },
|
|
SCRATCH = { pp = 35 },
|
|
},
|
|
items = {
|
|
POTION = { pocket = "ITEM" },
|
|
MASTER_BALL = { pocket = "BALL" },
|
|
FLOWER_MAIL = { pocket = "ITEM" },
|
|
},
|
|
maps = {},
|
|
}
|
|
|
|
local function newState(version)
|
|
version = version or "gold"
|
|
local prior = GameVersion.get()
|
|
GameVersion.set(version)
|
|
local S = State.new()
|
|
S.data = data
|
|
S.cat = Catalog.build(data)
|
|
S.save = Save2.newGame()
|
|
S.version = version
|
|
Gen.ensureBoxes(S.save)
|
|
GameVersion.set(prior)
|
|
return S
|
|
end
|
|
|
|
do
|
|
GameVersion.set("gold")
|
|
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
|
|
|
|
do
|
|
GameVersion.set("silver")
|
|
eq(Save2.newGame().version, "silver", "a silver boot stamps silver")
|
|
eq(Save2.newGame().player.name, "SILVER", "with silver's preset name")
|
|
eq(Gen.of(Save2.newGame()), 2, "Gen.of silver newGame")
|
|
GameVersion.set("gold")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
|
|
local S1 = State.new()
|
|
S1.data = {
|
|
pokemon = {
|
|
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
|
|
BROKEN = { baseStats = { hp = 1 } },
|
|
},
|
|
}
|
|
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
|
|
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
|
|
end
|
|
|
|
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
|
local S = newState(version)
|
|
Ops.partyAdd(S)
|
|
eq(#S.save.party, 1, "partyAdd on " .. version)
|
|
local mon = S.save.party[1]
|
|
eq(mon.species, "CYNDAQUIL", version .. " first catalog species")
|
|
check(mon.experience ~= nil, version .. " mon has experience")
|
|
check(mon.exp == nil or mon.experience ~= nil,
|
|
version .. " does not rely on gen1 exp")
|
|
check(mon.stats.specialAttack and mon.stats.specialDefense,
|
|
version .. " stats have spa/spd")
|
|
check(mon.happiness ~= nil, version .. " mon has happiness")
|
|
eq(mon.ot, S.save.player.name, version .. " stampOT copies player name")
|
|
|
|
Ops.setLevel(S, mon, 20)
|
|
eq(mon.level, 20, version .. " setLevel 20")
|
|
check(mon.experience > 0, version .. " experience resynced")
|
|
|
|
Ops.setHappiness(S, mon, 200)
|
|
eq(mon.happiness, 200, version .. " happiness 200")
|
|
Ops.setPokerus(S, mon, 15)
|
|
eq(mon.pokerus, 15, version .. " pokerus byte")
|
|
Ops.setHeldItem(S, mon, "POTION")
|
|
eq(mon.item, "POTION", version .. " held item")
|
|
|
|
eq(mon.name, "CYNDAQUIL", version .. " new mon copies species display name")
|
|
Ops.setSpecies(S, mon, "TOTODILE")
|
|
eq(mon.species, "TOTODILE", version .. " setSpecies id")
|
|
eq(mon.name, "TOTODILE", version .. " setSpecies rewrites the display name")
|
|
check(mon.nickname == nil, version .. " setSpecies does not invent a nickname")
|
|
eq(mon.types[1], "WATER", version .. " setSpecies rewrites copied types")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
Ops.partyAdd(S)
|
|
Ops.partyAdd(S)
|
|
local Mail = require("src.core.gen2.Mail")
|
|
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
|
|
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
|
|
S.selectedParty = 1
|
|
Ops.partyMove(S, 1)
|
|
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
|
|
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
|
|
S.selectedParty = 1
|
|
check(Ops.partyRemove(S) == false, "partyRemove arms")
|
|
check(Ops.partyRemove(S) == true, "partyRemove commits")
|
|
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
|
|
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
Ops.partyAdd(S)
|
|
local mon = S.save.party[1]
|
|
local Mail = require("src.core.gen2.Mail")
|
|
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
|
|
eq(mon.item, "FLOWER_MAIL", "held mail item")
|
|
local letter = Mail.state(S.save).party[1]
|
|
check(letter ~= nil, "giving mail writes sPartyMail")
|
|
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
|
|
Ops.setSpecies(S, mon, "TOTODILE")
|
|
eq(Mail.state(S.save).party[1].species, "TOTODILE",
|
|
"setSpecies updates the letter's species copy")
|
|
Ops.setHeldItem(S, mon, "POTION")
|
|
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
|
|
end
|
|
|
|
do
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
local S = newState()
|
|
local stale = Mon.new(data, "CYNDAQUIL", 5)
|
|
stale.species = "TOTODILE"
|
|
stale.name = "CYNDAQUIL"
|
|
stale.types = { "FIRE" }
|
|
S.save.dayCare = { man = { mon = stale }, lady = {} }
|
|
Mon.syncSaveIdentity(S.save, data)
|
|
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
|
|
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
|
|
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
|
|
"ABRA", "displayName prefers the species copy over the id")
|
|
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
|
|
"BOB", "displayName prefers nickname")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
eq(Ops.boxCount(S), 14, "14 gold boxes")
|
|
eq(Ops.boxCapacity(S), 20, "20 per box")
|
|
Ops.boxAdd(S)
|
|
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
Ops.partyAdd(S)
|
|
S.save.party[1].hp = 0
|
|
Ops.partyAdd(S)
|
|
S.selectedParty = 1
|
|
S.selectedBox = 1
|
|
local ok = Ops.deposit(S)
|
|
check(ok, "deposit fainted mon while a healthy remains")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
Ops.partyAdd(S)
|
|
Ops.partyAdd(S)
|
|
S.selectedParty = 1
|
|
S.selectedBox = 1
|
|
local ok = Ops.deposit(S)
|
|
check(ok, "deposit one of two healthy mons")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
Ops.partyAdd(S)
|
|
S.selectedParty = 1
|
|
S.selectedBox = 1
|
|
local ok = Ops.deposit(S)
|
|
check(not ok, "refuse depositing last healthy mon")
|
|
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
|
|
or S.status:find("POKEMON") or S.status:find("last"),
|
|
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
|
|
eq(#S.save.party, 1, "party still has the mon")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
eq(Gen.money(S.save), 3000, "gold start money on player")
|
|
Ops.addMoney(S, 1000)
|
|
eq(S.save.player.money, 4000, "money writes player.money")
|
|
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
|
|
Ops.maxMoney(S)
|
|
eq(S.save.player.money, 999999, "money cap")
|
|
Ops.addCoins(S, 250)
|
|
eq(S.save.player.coins, 250, "coins write player.coins")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
|
|
Ops.toggleBadge(S, "ZEPHYR")
|
|
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
|
|
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
|
|
Ops.toggleBadge(S, "BOULDER")
|
|
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
Ops.dexOwned(S, "CYNDAQUIL", true)
|
|
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
|
|
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
|
|
"does not write owned on gold")
|
|
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
|
|
local _, owned = Ops.dexCounts(S)
|
|
eq(owned, 1, "dexCounts reads caught")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
local name = "EVENT_BEAT_FALKNER"
|
|
Ops.setFlag(S, name, true)
|
|
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
|
|
check(S.save.flags[name] == nil, "numeric flags are not string keys")
|
|
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
|
|
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
|
|
end
|
|
|
|
do
|
|
local S = newState()
|
|
S.mapId = "NEW_BARK_TOWN"
|
|
S.mapClickCell = { cx = 4, cy = 5 }
|
|
Ops.setPlayerHere(S)
|
|
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
|
|
eq(S.save.position.x, 4, "position.x")
|
|
eq(S.save.position.y, 5, "position.y")
|
|
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
|
|
"does not write player.map on gold")
|
|
end
|
|
|
|
do
|
|
local encoded = SaveData.encode(Save2.newGame())
|
|
local back = SaveData.decode(encoded)
|
|
eq(back.generation, 2, "round-trip keeps generation 2")
|
|
end
|
|
|
|
do
|
|
local names = Catalog.goldEventList()
|
|
local hasFalkner = false
|
|
for _, n in ipairs(names) do
|
|
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
|
|
end
|
|
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
|
|
end
|
|
|
|
do
|
|
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
|
|
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
|
|
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
|
|
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
|
|
"Gen.maps falls back to maps")
|
|
local mansion = Gen.maps({
|
|
maps = {
|
|
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
|
|
},
|
|
gen2Maps = {
|
|
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
|
|
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
|
|
},
|
|
})
|
|
eq(mansion.CELADON_MANSION_2F.width, 4,
|
|
"Gen.maps keeps extractor width under a gen2Maps objects patch")
|
|
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
|
|
"Gen.maps still applies the gen2Maps patch fields")
|
|
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
|
|
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
|
|
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
|
|
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
|
|
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
|
|
"Gen.tilesets prefers gen2Tilesets")
|
|
|
|
-- bindGoldData bound gen2Palettes/gen2Icons/gen2Pokedex/gen2Landmarks/
|
|
-- gen2Roofs/gen2Sprites through loadGen but never gen2Constants, so any
|
|
-- mod reading mod.content.constants:get(...) under a save-editor Gold
|
|
-- bootstrap saw an empty table where it expected the cart's ordered name
|
|
-- lists. loadGen falls back to require("data.generated.constants") when
|
|
-- the ROM cache has nothing active, which is what a checkout with no
|
|
-- ROM imported hits too -- stub that module the same way to prove the
|
|
-- wiring without needing a real Gold extraction.
|
|
package.loaded["data.generated.constants"] = { badges = { "ZEPHYR" } }
|
|
local withConstants = Gen.bindGoldData({})
|
|
package.loaded["data.generated.constants"] = nil
|
|
check(withConstants.gen2Constants ~= nil,
|
|
"bindGoldData populates gen2Constants")
|
|
check(withConstants.gen2Constants and withConstants.gen2Constants.badges
|
|
and withConstants.gen2Constants.badges[1] == "ZEPHYR",
|
|
"gen2Constants carries the extractor's own name lists")
|
|
end
|
|
|
|
do
|
|
local Map2 = require("src.world.gen2.Map")
|
|
local MapPreview = require("src.world.gen2.MapPreview")
|
|
local def = {
|
|
id = "AZALEA_GYM", tileset = "TILESET_GYM",
|
|
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
|
|
warps = {}, environment = "INDOOR",
|
|
}
|
|
local tileset = {
|
|
id = "TILESET_GYM",
|
|
image = "assets/generated/tilesets/gym.png",
|
|
tilesPerRow = 16,
|
|
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
|
|
}
|
|
local map = Map2.new(def, tileset)
|
|
check(map.renderer == nil, "Map2 does not ship a renderer")
|
|
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
|
|
local renderer = MapPreview.renderer(baker, map)
|
|
check(renderer ~= nil and renderer.draw ~= nil,
|
|
"MapPreview attaches a draw for a Gold map")
|
|
end
|
|
|
|
do
|
|
local memfs = {
|
|
files = {
|
|
["saves/gold/slot1.lua"] = 'return { version = "gold", generation = 2, player = { name = "GOLD" } }',
|
|
["options.lua"] = 'return { textSpeed = 3 }',
|
|
},
|
|
getInfo = function(self, path)
|
|
return self.files[path] and { type = "file" } or nil
|
|
end,
|
|
read = function(self, path)
|
|
return self.files[path]
|
|
end,
|
|
write = function(self, path, data)
|
|
self.files[path] = data
|
|
return true
|
|
end,
|
|
remove = function(self, path)
|
|
self.files[path] = nil
|
|
return true
|
|
end,
|
|
}
|
|
local main, _, _ = SaveData.saveFilename("gold")
|
|
check(main ~= nil, "saveFilename resolves for gold")
|
|
end
|
|
|
|
-- Gold's cache has no text_pointers / trainer_headers / field. Data:load
|
|
-- used to throw in seedDefaults (self.field.boot) after filling pokemon
|
|
-- with provenance scalars. That is the Android first-Edit CTD: the APK
|
|
-- cannot fall back to Red's source-tree copies the way a desktop checkout
|
|
-- can.
|
|
do
|
|
GameVersion.set("gold")
|
|
local Data = require("src.core.Data")
|
|
Data.constants = {}
|
|
Data.pokemon = { generation = 2, CYNDAQUIL = { dex = 155 } }
|
|
Data.maps = {}
|
|
Data.field = nil
|
|
Data.trainer_headers = nil
|
|
local ok, err = pcall(function() Data:seedDefaults() end)
|
|
check(ok, "gold seedDefaults survives a Gold-shaped cache: " .. tostring(err))
|
|
check(type(Data.field) == "table", "seedDefaults creates field when Gold omitted it")
|
|
eq(Data.constants.dexSize, 155, "dexSize ignores pokemon.generation scalar")
|
|
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
|