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
+165
View File
@@ -0,0 +1,165 @@
-- Crystal manifest shape and the specials surface the extractor pins to it.
-- ROM-free: reads tools/rom_manifest_crystal.json out of the source tree.
-- luajit tests/crystal_import_test.lua
-- Also dofile'd by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("crystal import")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Json = require("src.link.Json")
local GameVersion = require("src.core.GameVersion")
local function manifest(path)
local file = assert(io.open(path, "r"))
local data = assert(Json.decode(file:read("*a")))
file:close()
return data
end
local function size(tbl)
local n = 0
for _ in pairs(tbl or {}) do n = n + 1 end
return n
end
local crystal = manifest("tools/rom_manifest_crystal.json")
local gold = manifest("tools/rom_manifest_gold.json")
-- ------- 1. the manifest the launcher row points at
eq(GameVersion.VERSIONS.crystal.manifest, "tools/rom_manifest_crystal.json",
"the crystal row names this manifest")
eq(crystal.romSha1, GameVersion.VERSIONS.crystal.sha1,
"and the manifest carries the same sha1")
eq(crystal.generation, 2, "generation 2")
eq(crystal.format, gold.format, "same manifest format as Gold")
-- ------- 2. content counts
eq(size(crystal.maps), 388, "388 maps")
eq(size(crystal.tilesets), 36, "36 tilesets")
eq(size(crystal.pokemonAssets), 251, "251 pokemon asset rows")
eq(size(crystal.constants), 50, "50 constants keys")
eq(type(crystal.constants.engineFlagOrder), "table",
"the 50th is engineFlagOrder, the key Gold has no counterpart for")
check(size(crystal.symbols) > 2000,
("symbols table is populated (%d)"):format(size(crystal.symbols)))
check(size(crystal.charmap) > 200,
("charmap is populated (%d)"):format(size(crystal.charmap)))
check(#(crystal.fontCharmap or {}) > 200,
("fontCharmap is populated (%d)"):format(#(crystal.fontCharmap or {})))
-- pokecrystal/constants/map_constants.asm:504, pokegold:484
eq(size(gold.maps), 368, "Gold names 368")
local added, removed = 0, 0
for name in pairs(crystal.maps) do
if not gold.maps[name] then added = added + 1 end
end
for name in pairs(gold.maps) do
if not crystal.maps[name] then removed = removed + 1 end
end
eq(added, 21, "Crystal adds 21 maps")
eq(removed, 1, "and drops one")
-- pokegold/constants/map_constants.asm:152
eq(crystal.maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil,
"the one Gold map Crystal drops is ECRUTEAK_TIN_TOWER_BACK_ENTRANCE")
check(crystal.maps.BATTLE_TOWER_1F ~= nil, "and BATTLE_TOWER_1F is new")
-- ------- 3. every map row is addressable
local badMap
for name, row in pairs(crystal.maps) do
if type(row) ~= "table" then badMap = name; break end
end
eq(badMap, nil, "every map row is a table")
-- ------- 4. the text labels resolve, as they must for the Dialogue stage
local labels = (crystal.text or {}).labels or {}
check(#labels > 800, ("crystal names %d text labels"):format(#labels))
local unresolved = {}
for _, label in ipairs(labels) do
if not crystal.symbols[label] then unresolved[#unresolved + 1] = label end
end
eq(#unresolved, 0,
("every crystal text label resolves to a symbol (%s)")
:format(table.concat(unresolved, ", "):sub(1, 60)))
-- ------- 5. specials: constants.specialOrder against the handler table
local Specials = require("src.script.gen2.Specials")
local order = crystal.constants.specialOrder
check(type(order) == "table", "constants.specialOrder is a list")
eq(#order, 169, "Crystal's SpecialsPointers has 169 rows")
local missing = {}
for index, name in ipairs(order) do
if not Specials.ALL[name] then
missing[#missing + 1] = ("%d (%s)"):format(index - 1, name)
end
end
eq(#missing, 0,
("every crystal special has a handler (%s)")
:format(table.concat(missing, ", "):sub(1, 80)))
local goldOrder = gold.constants.specialOrder
local goldMissing = {}
for index, name in ipairs(goldOrder or {}) do
if not Specials.ALL[name] then
goldMissing[#goldMissing + 1] = ("%d (%s)"):format(index - 1, name)
end
end
eq(#goldMissing, 0,
("every gold special has a handler too (%s)")
:format(table.concat(goldMissing, ", "):sub(1, 80)))
local sameOrder = #order == #(goldOrder or {})
if sameOrder then
for index, name in ipairs(order) do
if goldOrder[index] ~= name then sameOrder = false; break end
end
end
check(not sameOrder, "the Crystal and Gold special orders are not the same")
-- ------- 6. handler bookkeeping
local overlap = {}
for name in pairs(Specials.STUBS) do
if Specials.HANDLERS[name] then overlap[#overlap + 1] = name end
end
eq(#overlap, 0,
("HANDLERS and STUBS are disjoint (%s)"):format(table.concat(overlap, ", ")))
local unexplained = {}
for name in pairs(Specials.STUBS) do
local reason = (Specials.STUB_REASONS or {})[name]
if type(reason) ~= "string" or reason == "" then
unexplained[#unexplained + 1] = name
end
end
eq(#unexplained, 0,
("every stub records a reason (%s)")
:format(table.concat(unexplained, ", "):sub(1, 80)))
-- ------- 7. the Crystal-only assets the completeness gate pins
local importerSource = assert(io.open("src/import/RomImporter.lua", "r"))
local importerText = importerSource:read("*a")
importerSource:close()
for _, path in ipairs({
"assets/generated/title/crystal_logo.png",
"assets/generated/title/crystal_wordmark.png",
"assets/generated/title/crystal_suicune.png",
"assets/generated/splash/ditto.png",
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
}) do
check(importerText:find(path, 1, true) ~= nil,
"RomImporter still requires " .. path)
end
S.finish()
+158
View File
@@ -0,0 +1,158 @@
-- Crystal world data: the roamer roster and swarm pairs that differ from
-- Gold, then the facts only a real Crystal cache can answer.
-- Self-contained: `luajit tests/crystal_world_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("crystal world")
local check, eq = S.check, S.eq
local Permissions = require("src.world.gen2.Permissions")
local Roamers = require("src.core.gen2.Roamers")
local Swarm = Roamers.Swarm
-- ---------------------------------------------------------------- ROM-free
check(Permissions.isWalkable(0x00), "COLL_FLOOR walkable")
check(not Permissions.isWalkable(0x07), "COLL_WALL blocked")
-- pokecrystal/constants/collision_constants.asm:19
check(Permissions.isGrass(0x18), "COLL_TALL_GRASS is grass")
check(Permissions.isWater(0x29), "COLL_WATER is water")
check(Permissions.isWarpCollision(0x71), "COLL_DOOR is a warp")
check(Permissions.isLedge(0xa0), "COLL_HOP_DOWN is a ledge")
eq(Permissions.of(0xff), Permissions.WALL, "a missing coll reads as wall")
local bare = {}
Roamers.init(bare)
eq(#bare.roamers, 3, "no cache -> the three-beast Gold roster")
check(Roamers.SPECIES ~= nil and #Roamers.SPECIES == 3,
"Roamers.SPECIES survives as the ROM-free fallback")
-- pokecrystal/constants/script_constants.asm:256-257
local pair = {}
Swarm.set(pair, "DARK_CAVE_VIOLET_ENTRANCE", 0)
Swarm.set(pair, "ROUTE_35", 1)
eq(pair.swarmMaps.DUNSPARCE, "DARK_CAVE_VIOLET_ENTRANCE", "SWARM_DUNSPARCE key")
eq(pair.swarmMaps.YANMA, "ROUTE_35", "SWARM_YANMA key, independently")
eq(Swarm.onMap(pair, "ROUTE_35"), "YANMA", "the Yanma map answers YANMA")
eq(Swarm.mapId(pair, "YANMA"), "ROUTE_35", "mapId takes a kind")
eq(Swarm.mapId(pair), "DARK_CAVE_VIOLET_ENTRANCE", "and defaults to Gold's key")
Swarm.timeEvents(pair, 100)
check(Swarm.timeEvents(pair, 101), "the next day ends the swarm")
eq(Swarm.onMap(pair, "ROUTE_35"), nil, "and both pairs are stranded")
local legacy = { swarmMap = "ROUTE_35", dailyFlags = { swarm = true } }
eq(Swarm.mapId(legacy), "ROUTE_35", "an old scalar save answers mapId")
eq(Swarm.onMap(legacy, "ROUTE_35"), "DUNSPARCE", "normalized onto the Gold key")
eq(Swarm.onMap(legacy, "ROUTE_36"), nil, "another map is not the swarm map")
check(Swarm.entry(legacy, { swarmGrass = { ROUTE_35 = { "row" } }, grass = {} },
"ROUTE_35", "grass") ~= nil, "and its swarm table still resolves")
-- ------------------------------------------------------------ cache-gated
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local mapsPath = cache .. "/data/generated/maps.lua"
local mapsFile = io.open(mapsPath, "r")
if not mapsFile then
check(true, "crystal cache absent : SKIP")
S.finish()
return
end
mapsFile: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 maps = loadLua("data/generated/maps.lua")
local encounters = loadLua("data/generated/encounters.lua")
local tilesets = loadLua("data/generated/tilesets.lua")
local constants = loadLua("data/generated/constants.lua")
check(maps ~= nil, "maps.lua loads")
check(encounters ~= nil, "encounters.lua loads")
check(tilesets ~= nil, "tilesets.lua loads")
check(constants ~= nil, "constants.lua loads")
-- ---- 1. the maps Phase 1 has to reach
local count = 0
for _ in pairs(maps or {}) do count = count + 1 end
eq(count, 388, "the cache carries 388 maps")
for _, id in ipairs({
"NEW_BARK_TOWN", "PLAYERS_HOUSE_1F", "PLAYERS_HOUSE_2F", "ELMS_LAB",
"ROUTE_29", "CHERRYGROVE_CITY", "ROUTE_30", "ROUTE_31", "VIOLET_CITY",
"SPROUT_TOWER_1F", "VIOLET_GYM",
}) do
check(maps[id] ~= nil, "the New Bark to Violet route has " .. id)
end
check(maps.BATTLE_TOWER_1F ~= nil, "and the Crystal-only Battle Tower")
-- pokegold/constants/map_constants.asm:152
eq(maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil,
"the one map Crystal drops is absent")
-- ---- 2. roamers (CT-4)
check(type(encounters.roamMons) == "table",
"the cache emits encounters.roamMons")
eq(#encounters.roamMons, 2, "Crystal seeds two beasts, not three")
eq(encounters.roamMons[1].species, "RAIKOU", "slot 1 is Raikou")
eq(encounters.roamMons[1].map, "ROUTE_42", "on Route 42")
eq(encounters.roamMons[2].species, "ENTEI", "slot 2 is Entei")
eq(encounters.roamMons[2].map, "ROUTE_37", "on Route 37")
eq(encounters.roamMons[1].level, 40, "both start at level 40")
eq(encounters.roamMons[2].level, 40, "both start at level 40")
local save = {}
Roamers.init(save, { encounters = encounters })
eq(#save.roamers, 2, "Roamers.init over the cache builds two slots")
eq(save.roamers[1].species, "RAIKOU", "slot 1 Raikou")
eq(save.roamers[2].species, "ENTEI", "slot 2 Entei")
eq(save.roamers[3], nil, "and no Suicune slot")
eq(save.roamers[1].hp, 0, "hp is zeroed so stats regenerate on contact")
local data = {}
Roamers.init(data, { data = { gen2Encounters = encounters } })
eq(#data.roamers, 2, "the opts.data path resolves the same roster")
eq(Roamers.checkEncounter(save, "ROUTE_38", false, function() return 3 end), nil,
"the Suicune slot roll finds nothing on a Crystal save")
local hit = Roamers.checkEncounter(save, "ROUTE_42", false, function() return 1 end)
check(hit and hit.species == "RAIKOU", "the Raikou roll still hits")
-- ---- 3. the swarm tables the kind byte indexes
check(encounters.swarmGrass ~= nil, "the cache carries swarmGrass")
check(encounters.swarmGrass.DARK_CAVE_VIOLET_ENTRANCE ~= nil,
"with the Dunsparce table")
check(encounters.swarmGrass.ROUTE_35 ~= nil, "and the Yanma table")
-- ---- 4. the tileset palette-map bank
-- pokecrystal.sym 13:40e5 TilesetJohtoPalMap (pokegold.sym 02:40c7)
local johto = (tilesets or {}).TILESET_JOHTO
check(johto ~= nil, "TILESET_JOHTO is in the cache")
eq(johto and johto.palMap and johto.palMap.bank, 0x13,
"TilesetJohtoPalMap is read out of bank $13")
eq(johto and johto.palMap and johto.palMap.address, 0x40e5,
"at $40e5")
local kanto = (tilesets or {}).TILESET_KANTO
eq(kanto and kanto.palMap and kanto.palMap.bank, 0x13,
"TilesetKantoPalMap is bank $13 too")
eq(kanto and kanto.palMap and kanto.palMap.address, 0x4075, "at $4075")
check(johto and johto.tilePalettes and #johto.tilePalettes > 0,
"and the map decoded to a per-tile palette list")
-- ---- 5. the special table the cache pins
eq(#(constants.specialOrder or {}), 169,
"the cache's specialOrder has Crystal's 169 rows")
S.finish()
@@ -0,0 +1,173 @@
-- The Battle Tower lobby, driven in a real game.
--
-- POKEPORT_IDENTITY=lead-card POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower \
-- POKEPORT_DRIVER=tests/drivers/crystal_battle_tower_shots.lua love .
--
-- Three sections, all on maps/BattleTower1F.asm's own extracted script:
-- A an illegal party, so _CheckForBattleTowerRules prints its refusals
-- B a legal party through Menu_ChallengeExplanationCancel and the room menu
-- C the walk to the elevator the chosen room starts
--
-- Section B overrides the TryQuickSave stub for the length of the run:
-- src/script/gen2/Specials.lua:2516 answers 0, and BattleTower1F.asm:84-85
-- backs out of the whole challenge on that, so the desk cannot be walked
-- past without it.
local U = require("tests.drivers.util")
local BattleTower = require("src.core.gen2.BattleTower")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
local Vm = require("src.script.gen2.Vm")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function top() return game.stack:top() end
local function isMenu() return getmetatable(top()) == ScriptMenu end
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local function party(spec)
local list = {}
for _, row in ipairs(spec) do
local m = Mon.new(game.data, row[1], row[2])
m.item = row[3]
list[#list + 1] = m
end
game.save.party = list
end
local function enterLobby()
while top() do tap("b", 2) end
assert(world:setMap("BATTLE_TOWER_1F", 7, 7, "up"), "no BATTLE_TOWER_1F")
U.wait(40)
end
-- maps/BattleTower1F.asm:55-73: A through the welcome, NO to the
-- explanation offer, and the menu is the next command.
local function talkToMenu(limit)
for _ = 1, limit or 200 do
if isMenu() then return true end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
return false
end
say("--- A: an illegal party at the desk")
party({ { "TYPHLOSION", 20 } })
enterLobby()
shot("lobby")
ok(talkToMenu(), "the receptionist reaches Menu_ChallengeExplanationCancel")
shot("challenge-menu")
ok(isMenu() and #top().items == 3, "three rows: Challenge/Explanation/Cancel")
tap("a", 6)
local pages = {}
for _ = 1, 40 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
shot("rules-" .. #pages)
end
if not world:busy() and top() == nil then break end
tap("a", 4)
end
local joined = table.concat(pages, " | ")
say("pages: " .. joined)
ok(joined:find("You're not ready", 1, true) ~= nil,
"_ExcuseMeYoureNotReadyText printed")
ok(joined:find("Only three", 1, true) ~= nil,
"_OnlyThreeMonMayBeEnteredText printed")
ok(joined:find("Please return when", 1, true) ~= nil,
"_BattleTowerReturnWhenReadyText printed")
say("--- B: a legal party, the room menu, the walk out")
local savedQuickSave = Vm.SPECIALS.TryQuickSave
Vm.SPECIALS.TryQuickSave = function(vm) vm.scriptVar = 1 end
party({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20, "BERRY" },
{ "MEGANIUM", 20, "GOLD_BERRY" } })
enterLobby()
ok(talkToMenu(), "the desk reaches the menu again")
tap("a", 6)
local sawSavePrompt = false
for _ = 1, 120 do
if isRoomMenu() then break end
if world.choicebox then
sawSavePrompt = true
shot("save-prompt")
tap("a", 6)
else
tap("a", 4)
end
end
ok(sawSavePrompt, "Text_SaveBeforeEnteringBattleRoom asked first")
ok(isRoomMenu(), "special BattleTowerRoomMenu pushed Gen2BattleTowerMenu")
shot("room-menu")
if isRoomMenu() then
local screen = top()
ok(#screen.rows == BattleTower.PRE_HOF_LEVEL_GROUPS,
"no Hall of Fame, so four rooms are offered")
-- L:10 with an L20 party is the refusal at mobile_46.asm:3915-3917.
tap("a", 6)
ok(screen.phase == "message", "picking L:10 prints the level refusal")
shot("tops-this-level")
for _ = 1, 150 do
if screen.phase == "pick" then break end
U.wait(1)
end
ok(screen.phase == "pick", "and the menu comes back after the hold")
tap("up", 6)
shot("room-menu-l20")
tap("a", 6)
end
for _ = 1, 240 do
if not world:busy() and top() == nil then break end
tap("a", 4)
end
local tower = BattleTower.state(game.save)
ok(tower.levelGroup ~= nil, "the level group survived the menu")
say("levelGroup=" .. tostring(game.world.vm and game.world.vm.btLevelGroup)
.. " reward=" .. tostring(tower.reward)
.. " challenge=" .. tostring(tower.challenge)
.. " map=" .. tostring(world.map and world.map.id))
ok(tower.reward ~= nil,
"BATTLETOWERACTION_CHOOSEREWARD banked a prize on the way out")
shot("after-desk")
U.wait(180)
shot("elevator-or-hallway")
say("map after the walk: " .. tostring(world.map and world.map.id))
Vm.SPECIALS.TryQuickSave = savedQuickSave
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+249
View File
@@ -0,0 +1,249 @@
-- Smoke: the whole Crystal boot chain, with no driver shortcut.
--
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_boot_smoke.lua love .
--
-- copyright -> GameFreak -> Crystal intro -> title -> intro menu -> NEW GAME
-- -> clock -> Oak speech -> name pick -> naming screen -> the bedroom ->
-- start menu -> outdoors -> a wild battle. Every step is asserted by the
-- class of the state on the stack, so a broken hand-off names the screen it
-- stalled on instead of just hanging.
local U = require("tests.drivers.util")
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local StartMenu = require("src.ui.gen2.StartMenu")
local TitleState = require("src.ui.gen2.TitleState")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-boot"
local log = io.open(os.getenv("POKEPORT_BOOT_LOG")
or (out .. "/boot.log"), "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
local function top()
return game.stack:top()
end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
local function pick(menu, value)
for i, item in ipairs(menu.list.items) do
if item.value == value then menu.list.index = i end
end
end
if GameVersion.get() ~= "crystal" then
bail("booted " .. tostring(GameVersion.get()) .. ", not crystal")
end
if GameVersion.engine() ~= "crystal" then
bail("engine lineage is " .. tostring(GameVersion.engine()))
end
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
U.wait(10)
if not isA(CopyrightSplash) then
bail("boot did not start at the copyright splash (top "
.. tostring(top()) .. ")")
end
say("OK copyright")
U.shot(game, out .. "/01-copyright.png")
waitFor("GameFreak presents", function() return isA(GameFreakPresents) end)
U.wait(90)
say("OK gamefreak")
U.shot(game, out .. "/02-gamefreak.png")
-- ../pokecrystal/engine/movie/intro.asm:1
waitFor("the Crystal intro", function() return isA(CrystalIntro) end)
U.wait(240)
say("OK crystal intro")
U.shot(game, out .. "/03-intro.png")
U.wait(240)
U.shot(game, out .. "/03b-intro.png")
tap("start")
waitFor("the title screen", function() return isA(TitleState) end)
U.wait(90)
say("OK title")
U.shot(game, out .. "/04-title.png")
for _ = 1, 3 do tap("start", 3) end
waitFor("the intro menu", function() return isA(MainMenu) end)
say("OK main menu")
U.shot(game, out .. "/05-mainmenu.png")
local menu = top()
pick(menu, "option")
tap("a")
waitFor("the options screen", function() return isA(OptionsMenu) end)
U.shot(game, out .. "/06-options.png")
tap("b")
waitFor("the intro menu again", function() return isA(MainMenu) end)
menu = top()
pick(menu, "new")
U.shot(game, out .. "/07-newgame.png")
tap("a")
-- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, which
-- PlayerProfileSetup runs first (engine/menus/intro_menu.asm:80-84).
waitFor("the gender screen", function() return isA(GenderSelect) end, 300)
say("OK gender select")
U.shot(game, out .. "/07b-gender.png")
tap("a")
-- ../pokecrystal/engine/menus/intro_menu.asm:628
waitFor("the clock screen", function() return isA(InitClock) end, 300)
say("OK init clock")
U.shot(game, out .. "/08-initclock.png")
for _ = 1, 60 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 300)
local oak = top()
say("OK oak speech, demo mon = " .. tostring(oak.demoSpecies))
if oak.demoSpecies ~= "WOOPER" then
bail("Oak's demo mon is " .. tostring(oak.demoSpecies)
.. ", Crystal's is WOOPER")
end
U.wait(60)
U.shot(game, out .. "/09-oak.png")
local shotDemo = false
for _ = 1, 400 do
if isA(NamePick) then break end
if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then
U.wait(45)
U.shot(game, out .. "/10-wooper.png")
shotDemo = true
end
tap("a", 2)
end
if not isA(NamePick) then
bail("Oak speech never reached the name picker (top "
.. tostring(top()) .. ")")
end
say("OK name pick" .. (shotDemo and " (demo pic captured)" or ""))
-- ../pokecrystal/engine/menus/intro_menu.asm:738 NamePlayer
waitFor("the name menu to slide in",
function() return top().slide == nil end, 240)
U.shot(game, out .. "/11-namepick.png")
local picker = top()
picker.cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
tap("a")
tap("right")
tap("a")
if naming.text ~= "AB" then
bail("typed name is " .. tostring(naming.text) .. ", expected AB")
end
say("OK naming screen")
U.shot(game, out .. "/12-naming.png")
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
for _ = 1, 600 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
if game.save.player.name ~= "AB" then
bail("player name is " .. tostring(game.save.player.name))
end
if game.world.map.id ~= "PLAYERS_HOUSE_2F" then
bail("new game started on " .. tostring(game.world.map.id)
.. ", expected the bedroom")
end
say("OK overworld, map = " .. game.world.map.id
.. ", name = " .. tostring(game.save.player.name))
U.wait(20)
U.shot(game, out .. "/13-bedroom.png")
tap("start")
waitFor("the start menu", function() return isA(StartMenu) end)
say("OK start menu")
U.shot(game, out .. "/14-startmenu.png")
tap("b")
waitFor("the overworld again", function() return top() == nil end)
if not game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") then
bail("could not load NEW_BARK_TOWN")
end
U.wait(30)
if game.world.map.id ~= "NEW_BARK_TOWN" then
bail("setMap landed on " .. tostring(game.world.map.id))
end
local tileset = game.world.map.tileset or {}
say("OK outdoors, tileset = " .. tostring(tileset.id)
.. ", tilePalettes = " .. tostring(#(tileset.tilePalettes or {})))
U.shot(game, out .. "/15-newbark.png")
local player = Mon.new(game.data, "CYNDAQUIL", 12)
if not (player and #player.moves > 0) then
bail("could not build a CYNDAQUIL from the Crystal pokemon.lua")
end
local wild = Mon.new(game.data, "WOOPER", 5)
if not wild then bail("could not build a wild WOOPER") end
game.save.party = { player }
if not game.world:startBattle({ wild = wild }) then
bail("startBattle failed")
end
U.wait(240)
say("OK battle, " .. player.species .. " L" .. player.level
.. " vs " .. wild.species .. " L" .. wild.level)
U.shot(game, out .. "/16-battle.png")
say("PASS crystal boot chain in " .. out)
if log then log:close() end
love.event.quit(0)
end
+227
View File
@@ -0,0 +1,227 @@
-- TryQuickSave and the Crystal script VARs, in a real Crystal game.
--
-- POKEPORT_IDENTITY=f2-crystal POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/f2 \
-- POKEPORT_DRIVER=tests/drivers/crystal_f2_save_and_vars.lua love .
--
-- A ../pokecrystal/maps/BattleTower1F.asm:80-86 with NO monkeypatch: the
-- desk's own `writetext / yesorno`, then `special TryQuickSave` -- the
-- overwrite prompt, the SAVING pages and a real file on disk -- and the
-- room menu it can only reach on a TRUE.
-- B ../pokecrystal/maps/RadioTower2F.asm:135-146: the correct password's
-- `readvar / addval 1 / writevar VAR_BLUECARDBALANCE`, through the real
-- World, over a save and a reload, and then BuenaPrize spending it.
local U = require("tests.drivers.util")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local BuenaPassword = require("src.ui.gen2.BuenaPassword")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
local Specials = require("src.script.gen2.Specials")
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-f2"
local fails, shots = 0, 0
local function say(line)
print("[driver] " .. line)
io.stdout:flush()
end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function top() return game.stack:top() end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
local save = game.save
ok(Specials.STUB_REASONS.TryQuickSave == nil,
"TryQuickSave is a handler, not a stub")
ok(Specials.STUB_REASONS.SampleKenjiBreakCountdown == nil,
"SampleKenjiBreakCountdown is a handler too")
--------------------------------------------------------------- section B
say("--- B: Buena's Blue Card, over a save and a reload")
ok(world:setMap("RADIO_TOWER_2F", 5, 5, "down") == true, "on RADIO_TOWER_2F")
U.wait(30)
local crystal = Save.crystalState(save)
crystal.buenaPassword.balance = 0
crystal.buenaPassword.word = 0x00
crystal.buenaPassword.day = nil
-- The three rows the correct-answer arm runs, straight off the extracted
-- opcode table, so the round trip is World:readVar -> Vm -> World:writeVar.
local function awardPoint()
local started = world.vm:start({
{ op = "readvar", var = VAR_BLUECARDBALANCE },
{ op = "addval", args = { 1 } },
{ op = "writevar", var = VAR_BLUECARDBALANCE },
})
ok(started == true, "the award rows started (vm idle)")
for _ = 1, 120 do
if not world.vm:running() then break end
U.wait(1)
end
end
for _ = 1, 3 do awardPoint() end
say("blue card balance: " .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
ok(world:readVar(VAR_BLUECARDBALANCE) == 3,
"three correct passwords are three points")
ok(crystal.buenaPassword.balance == 3, "and the points are in save.crystal")
-- The password itself is a RETVAR_ADDR_DE row too; BuenasPassword parks the
-- day's roll there through the same writevar path.
world:writeVar(VAR_BUENASPASSWORD, 0x24)
ok(world:readVar(VAR_BUENASPASSWORD) == 0x24, "wBuenasPassword reads back")
-- The map load that used to eat it: scriptVars is rebuilt empty at
-- World.lua:579.
ok(world:setMap("RADIO_TOWER_1F", 5, 5, "down") == true, "walked downstairs")
U.wait(20)
ok(world:readVar(VAR_BLUECARDBALANCE) == 3, "a map load does not eat it")
ok(game:writeSave() ~= false, "saved with the balance on it")
local reloaded = Save.load(save.version)
local reloadedBalance = reloaded and reloaded.crystal
and reloaded.crystal.buenaPassword
and reloaded.crystal.buenaPassword.balance
say("reloaded blue card balance: " .. tostring(reloadedBalance))
ok(reloadedBalance == 3, "and it survives a save and a reload")
ok(reloaded and reloaded.crystal.buenaPassword.word == 0x24,
"so does the password")
-- BuenaPrize, which could never dispense anything on a balance of 0.
-- data/items/buena_prizes.asm's first row is ULTRA_BALL at 2 points.
save.inventory = save.inventory or {}
save.inventory.ULTRA_BALL = nil
local prizeIndex
for index, name in ipairs(world.vm.specialOrder or {}) do
if name == "BuenaPrize" then prizeIndex = index - 1 break end
end
ok(prizeIndex ~= nil, "BuenaPrize is in constants.specialOrder")
world.vm:start({ { op = "special", id = prizeIndex } })
local sawPrizeMenu = false
for _ = 1, 300 do
if not world.vm:running() and top() == nil then break end
if getmetatable(top()) == BuenaPassword then
if not sawPrizeMenu then
sawPrizeMenu = true
say("prize menu balance shown: " .. tostring(top().balance))
shot("prize-menu")
tap("a", 6) -- ULTRA BALL
else
tap("b", 6) -- second pass: leave the shop
end
else
tap("a", 4)
end
end
ok(sawPrizeMenu, "BuenaPrize opened its list")
say("ULTRA_BALL x" .. tostring(save.inventory.ULTRA_BALL)
.. " balance=" .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
ok((save.inventory.ULTRA_BALL or 0) >= 1, "the prize was handed over")
ok(world:readVar(VAR_BLUECARDBALANCE) == 1, "and two points were spent")
shot("after-prize")
--------------------------------------------------------------- section A
say("--- A: the Battle Tower desk, with the real TryQuickSave")
-- A file has to already exist for AskOverwriteSaveFile to have anything to
-- ask about (`ld a, [wSaveFileExists] / and a / jr z, .erase`).
ok(game:writeSave() ~= false, "a first save is on disk")
ok(Save.exists(save.version), "Save.exists agrees")
save.party = {}
for _, row in ipairs({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20 },
{ "MEGANIUM", 20 } }) do
local mon = Mon.new(game.data, row[1], row[2])
save.party[#save.party + 1] = mon
end
for _ = 1, 40 do
if top() == nil then break end
tap("b", 2)
end
ok(world:setMap("BATTLE_TOWER_1F", 7, 7, "up") == true, "on BATTLE_TOWER_1F")
U.wait(40)
shot("tower-lobby")
local function isMenu() return getmetatable(top()) == ScriptMenu end
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
local reached = false
for _ = 1, 200 do
if isMenu() then reached = true break end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
ok(reached, "the receptionist reaches Menu_ChallengeExplanationCancel")
tap("a", 6) -- CHALLENGE
-- Two yes/no prompts now, not one: Text_SaveBeforeEnteringBattleRoom and
-- then AskOverwriteSaveFile's own, which the stub never asked.
local prompts, pages = 0, {}
for _ = 1, 240 do
if isRoomMenu() then break end
if world.choicebox then
prompts = prompts + 1
shot("prompt-" .. prompts)
say("prompt " .. prompts .. ": " .. tostring(world.lastText))
tap("a", 6)
else
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
end
tap("a", 4)
end
end
say("pages: " .. table.concat(pages, " | "))
ok(prompts >= 2,
"AskOverwriteSaveFile asked its own question (" .. prompts .. " prompts)")
local joined = table.concat(pages, " | ")
ok(joined:find("SAVING", 1, true) ~= nil, "the SAVING page was shown")
ok(joined:find("saved", 1, true) ~= nil, "and SavedTheGameText")
ok(isRoomMenu(), "TryQuickSave answered TRUE and the room menu opened")
shot("room-menu")
local onDisk = Save.load(save.version)
ok(onDisk ~= nil, "the challenge's own save reached disk")
ok(onDisk and onDisk.position and onDisk.position.map == "BATTLE_TOWER_1F",
"and it was written from the lobby: "
.. tostring(onDisk and onDisk.position and onDisk.position.map))
-- The room menu's own header is STATICMENU_DISABLE_B, so the way out of the
-- desk is forward: pick a room and let the walk to the elevator finish.
for _ = 1, 400 do
if not world:busy() and top() == nil then break end
tap("a", 4)
end
say("map after the desk: " .. tostring(world.map and world.map.id))
for _ = 1, 40 do
if top() == nil then break end
game.stack:pop()
end
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+260
View File
@@ -0,0 +1,260 @@
-- U2 verification: ENGINE_PLAYER_IS_FEMALE end to end, plus the four gendered
-- pictures and the Crystal surf refusal. Not a suite; a shot harness.
--
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 POKEPORT_GENDER=girl \
-- POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_flag_shots.lua love .
local U = require("tests.drivers.util")
local BattleState = require("src.ui.gen2.BattleState")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local MainMenu = require("src.ui.gen2.MainMenu")
local Mon = require("src.battle.gen2.Mon")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local Screens = require("src.ui.Screens")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/u2-gender"
local want = (os.getenv("POKEPORT_GENDER") or "girl"):lower()
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[u2] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function done(code)
if log then log:close() end
love.event.quit(code)
if code ~= 0 then error("driver failed", 0) end
coroutine.yield()
end
local function bail(reason)
say("FAIL " .. reason)
done(1)
end
local function top() return game.stack:top() end
local function isA(class)
local s = top()
return s ~= nil and getmetatable(s) == class
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return true end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
say("fixes.surfOntoNpc=" .. tostring(GameVersion.fixes().surfOntoNpc))
------------------------------------------------------------------ new game
U.wait(10)
for _ = 1, 1200 do
if isA(MainMenu) then break end
tap("start", 2)
end
waitFor("the main menu", function() return isA(MainMenu) end, 600)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
local gendered = false
for _ = 1, 400 do
if isA(GenderSelect) then gendered = true break end
if isA(OakSpeech) or isA(NamePick) then break end
U.wait(1)
end
if GameVersion.engine() == "crystal" then
if not gendered then bail("Crystal never offered InitGender") end
local pick = top()
pick.cursor = (want == "girl") and 2 or 1
U.shot(game, out .. "/00-gender.png")
say("OK gender screen, choosing " .. want)
tap("a")
else
if gendered then bail("Gold offered a gender prompt; it has none") end
say("OK no gender prompt on " .. GameVersion.get())
end
for _ = 1, 900 do
if game.phase == "play" and game.world and game.world.map then break end
if isA(NamingScreen) then
local naming = top()
naming.row = naming:bottomRow()
naming.col = 6
end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
local world = game.world
local save = game.save
say("OK overworld, name=" .. tostring(save.player.name)
.. " gender=" .. tostring(save.player.gender)
.. " sprite=" .. tostring(world:playerSpriteName()))
------------------------------------------------------ the engine flag itself
local id = FieldMoves.FEMALE_FLAG
say("FieldMoves.FEMALE_FLAG=" .. tostring(id))
if GameVersion.engine() == "crystal" then
if id ~= 99 then bail("female flag id is " .. tostring(id) .. ", want 99") end
local reads = world:engineFlag(id)
say("world:engineFlag(" .. id .. ")=" .. tostring(reads))
if reads ~= (want == "girl") then
bail("checkflag ENGINE_PLAYER_IS_FEMALE read " .. tostring(reads))
end
elseif id ~= nil then
bail("Gold bound a female flag id: " .. tostring(id))
end
------------------------------------------------------------- the three sites
local sites = {
{ "COPYCATS_HOUSE_2F", 3, 4, "up", "01-copycat" },
{ "ROUTE_37", 5, 9, "up", "02-route37" },
{ "POKECENTER_2F", 5, 5, "up", "03-pokecenter2f" },
}
for _, site in ipairs(sites) do
local mapId, x, y, facing, name = site[1], site[2], site[3], site[4], site[5]
if world.maps and world.maps[mapId] then
world:setMap(mapId, x, y, facing)
U.wait(40)
U.shot(game, out .. "/" .. name .. ".png")
local names = {}
for _, npc in ipairs(world.npcs or {}) do
names[#names + 1] = tostring(npc.def and npc.def.sprite)
end
say(name .. " map=" .. tostring(world.map.id)
.. " objects=" .. table.concat(names, ","))
else
say("SKIP " .. mapId .. ": not in this cache")
end
end
------------------------------------------------------------------- the pack
world:setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(20)
save.inventory = save.inventory or {}
save.inventory.POTION = 3
Screens.push(game, "Gen2PackMenu", { save = save, world = {},
onClose = function() game.stack:pop() end })
U.wait(20)
U.shot(game, out .. "/04-pack.png")
local packGfx = top() and top().gfx
local pals = packGfx and packGfx.gfx and packGfx.gfx.palettes
local menuGfx = game.data.gen2MenuGfx or {}
local femalePals = menuGfx.pack and menuGfx.pack.palettesFemale
say("pack palettes are the female set: "
.. tostring(pals ~= nil and pals == femalePals))
tap("b")
U.wait(10)
while top() do game.stack:pop() end
------------------------------------------------------------ battle back pic
local hud = menuGfx.battleHud or {}
say("battleHud.playerBack=" .. tostring(hud.playerBack)
.. " playerBackFemale=" .. tostring(hud.playerBackFemale))
save.party = { Mon.new(game.data, "CYNDAQUIL", 12) }
world:startBattle({ wild = Mon.new(game.data, "WOOPER", 5) })
local battle
for _ = 1, 600 do
if getmetatable(top()) == BattleState then battle = top() break end
U.wait(1)
end
if not battle then bail("startBattle never reached the battle screen") end
-- showPlayerTrainer is only true until SendOutPlayerMon; hold it so the
-- back pic is what the shot catches.
for _ = 1, 90 do
battle.showPlayerTrainer = true
U.wait(1)
end
battle.showPlayerTrainer = true
U.shot(game, out .. "/05-battle-backpic.png")
say("battle backpic path=" .. tostring(battle.playerBackPath))
while top() do game.stack:pop() end
U.wait(10)
------------------------------------------------------------- trainer card
Screens.push(game, "Gen2TrainerCard", { onClose = function()
game.stack:pop() end })
U.wait(30)
U.shot(game, out .. "/06-trainercard.png")
say("trainer card female arm=" .. tostring(top() and top().female))
tap("b")
U.wait(10)
while top() do game.stack:pop() end
------------------------------------------------------------- hall of fame
save.hallOfFame = nil
Screens.push(game, "Gen2HallOfFame", {
save = save, mode = "induct", text = world.text,
entry = { winCount = 1, mons = { { species = "CYNDAQUIL", level = 12,
nickname = "CYNDAQUIL", otId = 1234, gender = "male" } } },
onDone = function() game.stack:pop() end })
U.wait(20)
local hof = top()
if getmetatable(hof) ~= HallOfFame then
bail("Gen2HallOfFame did not push (top is " .. tostring(hof) .. ")")
end
-- The ceremony walks its own phases; freeze it so the two player cards can
-- be posed and shot.
hof.update = function() end
hof.scx, hof.scy = 0, 0
hof.phase = "player"
U.shot(game, out .. "/07-hof-trainerpic.png")
hof.phase = "playerBack"
U.shot(game, out .. "/08-hof-backpic.png")
say("hof backpic=" .. tostring(hof.playerBackPath)
.. " trainerPic=" .. tostring(hof.trainerPicPath))
while top() do game.stack:pop() end
U.wait(10)
------------------------------------------------------------- surf onto NPC
--
-- SurfFunction.TrySurf with a facing object: Crystal refuses, Gold does not
-- (../pokecrystal/engine/events/overworld.asm:364).
local ctx = {
save = { player = { badges = { FOG = true } } },
mon = { species = "LAPRAS" },
facing = "down",
facingColl = 0x29,
playerColl = 0x00,
playerState = FieldMoves.PLAYER_NORMAL,
facingObject = { id = "an NPC standing on the water" },
}
local refused = FieldMoves.surfFromMenu(ctx)
say("surf onto an NPC: ok=" .. tostring(refused.ok)
.. " text=" .. tostring(refused.text))
local shouldRefuse = GameVersion.fixes().surfOntoNpc == true
if (refused.ok ~= true) ~= shouldRefuse then
bail("surf-onto-NPC answered ok=" .. tostring(refused.ok)
.. " but fixes().surfOntoNpc=" .. tostring(shouldRefuse))
end
ctx.facingObject = nil
if not FieldMoves.surfFromMenu(ctx).ok then
bail("open water refused SURF")
end
say("PASS " .. GameVersion.get() .. " / " .. want .. " in " .. out)
done(0)
end
+234
View File
@@ -0,0 +1,234 @@
-- Crystal's InitGender screen, end to end, and the six things that follow from
-- the answer. POKEPORT_GENDER picks the arm: "girl", "boy" or "none" (Gold,
-- which must never see the screen at all).
--
-- POKEPORT_IDENTITY=<sandbox> POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_GENDER=girl POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_shots.lua love .
local U = require("tests.drivers.util")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local TrainerCard = require("src.ui.gen2.TrainerCard")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local Screens = require("src.ui.Screens")
return function(game)
local want = os.getenv("POKEPORT_GENDER") or "girl"
local out = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/crystal-gender-" .. want)
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
local function top() return game.stack:top() end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
local function eq(got, wanted, label)
if got ~= wanted then
bail(("%s: got %s, want %s"):format(label, tostring(got),
tostring(wanted)))
end
say("OK " .. label .. " = " .. tostring(got))
end
say("version=" .. tostring(GameVersion.get())
.. " engine=" .. tostring(GameVersion.engine())
.. " gender=" .. want)
-- Straight to the intro menu; the boot chain itself is crystal_boot_smoke's.
for _ = 1, 900 do
if isA(MainMenu) then break end
tap("start", 2)
end
if not isA(MainMenu) then
for _ = 1, 400 do
if isA(MainMenu) then break end
tap("a", 2)
end
end
waitFor("the intro menu", function() return isA(MainMenu) end)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
if want == "none" then
-- Gold and Silver: PlayerProfileSetup has no InitGender to farcall.
waitFor("the clock screen", function() return isA(InitClock) end, 400)
if isA(GenderSelect) then bail("Gold put up the gender screen") end
say("OK no gender screen; NEW GAME went straight to the clock")
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
for _, step in ipairs(speech.steps or {}) do
if step.kind == "gender" then bail("Gold's speech grew a gender beat") end
end
eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock",
"the first beat")
U.wait(40)
U.shot(game, out .. "/01-oak.png")
eq(game.save.player.gender, "male", "save.player.gender")
say("PASS gold has no gender prompt")
if log then log:close() end
love.event.quit(0)
return
end
waitFor("the gender screen", function() return isA(GenderSelect) end, 600)
local gender = top()
U.wait(20)
say("OK gender screen, cursor = " .. tostring(gender.cursor))
U.shot(game, out .. "/01-genderselect.png")
if want == "girl" then
tap("down")
U.wait(10)
U.shot(game, out .. "/02-genderselect-girl.png")
else
U.shot(game, out .. "/02-genderselect-boy.png")
end
tap("a")
waitFor("the clock screen", function() return isA(InitClock) end, 400)
eq(game.save.player.gender, want == "girl" and "female" or "male",
"wPlayerGender after the answer")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"NamePlayer's InitName default")
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
eq(speech.steps[1].id, "gender_select", "the beat the speech opened on")
local shotPic = false
for _ = 1, 500 do
if isA(NamePick) then break end
if not shotPic and isA(OakSpeech) then
local at = top().steps and top().steps[top().step]
if at and at.id == "ask_player_name" then
U.wait(45)
local wanted = (want == "girl") and top().playerPicFemale
or top().playerPic
if top().pic ~= wanted then
bail("DrawIntroPlayerPic put up the wrong pic")
end
U.shot(game, out .. "/03-intropic.png")
shotPic = true
end
end
tap("a", 2)
end
waitFor("the name picker", function() return isA(NamePick) end, 200)
waitFor("the name menu to slide in",
function() return top().slide == nil end, 240)
local picker = top()
eq(picker.items[2], want == "girl" and "KRIS" or "CHRIS", "preset row 1")
eq(picker.items[3], want == "girl" and "AMANDA" or "MAT", "preset row 2")
U.shot(game, out .. "/04-namepick.png")
picker.cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
eq(naming.gender, want == "girl" and "female" or "male",
"the keyboard's header icon gender")
U.wait(20)
U.shot(game, out .. "/05-naming.png")
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
for _ = 1, 700 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
eq(game.world.map.id, "PLAYERS_HOUSE_2F", "the bedroom")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"the name that reached the save")
local sprite = game.world.player and game.world.player.spriteDef
eq(sprite and sprite.id, FieldMoves.playerSprite(game.save.player.gender),
"the overworld sheet")
U.wait(20)
U.shot(game, out .. "/06-bedroom.png")
-- Walk a step so the sheet is seen mid-stride rather than only standing.
U.hold(game, "down", 20)
U.wait(20)
U.shot(game, out .. "/07-bedroom-walk.png")
-- The card, opened directly: the START-menu route is Gen2StartMenu's and is
-- already covered by crystal_boot_smoke.
game.save.player.badges = { ZEPHYR = true, HIVE = true }
local card = Screens.push(game, "Gen2TrainerCard",
{ save = game.save, onClose = function() end })
if getmetatable(card) ~= TrainerCard then bail("the card did not open") end
eq(card.female, want == "girl", "GetCardPic picked KrisCardPic")
U.wait(30)
U.shot(game, out .. "/08-trainercard.png")
tap("right")
U.wait(20)
U.shot(game, out .. "/09-trainercard-badges.png")
game.stack:pop()
local gear = Screens.push(game, "Gen2Pokegear", { save = game.save })
U.wait(30)
local pals = gear.pals and gear:pals()
local femalePals = gear.gfx and gear.gfx.palettesFemale
if want == "girl" and femalePals and pals ~= femalePals then
bail("the gear kept MalePokegearPals for Kris")
end
if want == "boy" and femalePals and pals == femalePals then
bail("the gear took FemalePokegearPals for Chris")
end
say("OK pokegear palettes = " .. (pals == femalePals and "female" or "male"))
U.shot(game, out .. "/10-pokegear.png")
game.stack:pop()
say("PASS crystal gender run (" .. want .. ") in " .. out)
if log then log:close() end
love.event.quit(0)
end
+117
View File
@@ -0,0 +1,117 @@
local U = require("tests.drivers.util")
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
local TitleState = require("src.ui.gen2.TitleState")
local INTERVAL = 20
local LIMIT = 4000
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-intro"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "")
or INTERVAL
U.wait(30)
local assets = game.data and game.data.gen2Intro
if not (assets and assets.acts) then
U.log("SKIP no crystal intro acts in this cache -- re-import crystal")
return
end
local finished = false
local intro = CrystalIntro.new(game, {
onDone = function() finished = true end,
})
game.stack:clear()
game.stack:push(intro)
local shots, scene = 0, 0
while not finished and intro.frames < LIMIT do
U.wait(interval)
if intro.scene ~= scene then
scene = intro.scene
U.log(("scene %d at frame %d (scx=%02x scy=%02x)")
:format(scene, intro.frames, intro.scx % 256, intro.scy % 256))
end
if not finished then
U.shot(game, ("%s/intro-%04d-scene%02d.png")
:format(out, intro.frames, intro.scene))
shots = shots + 1
end
end
assert(finished,
"the movie never reached IntroScene28 (frame " .. intro.frames .. ")")
U.log(("movie done: %d shots over %d frames"):format(shots, intro.frames))
local skipped = false
local intro2 = CrystalIntro.new(game, {
onDone = function() skipped = true end,
})
game.stack:clear()
game.stack:push(intro2)
U.wait(45)
U.tap(game, "b")
U.wait(3)
assert(skipped and intro2.skipped, "a button did not skip the intro")
U.log("skip via B verified at frame " .. intro2.frames)
game:showTitle()
U.wait(1)
local title = game.stack:top()
assert(getmetatable(title) == TitleState,
"showTitle left " .. tostring(title) .. " on the stack")
assert(#title.suicuneColor == 4,
"title.lua has no 4-frame Suicune set -- stale cache?")
assert(title.entrance, "title.lua has no entrance block -- stale cache?")
assert(title.suicuneX == 48 and title.suicuneY == 96,
("Suicune is at (%s, %s), expected (48, 96)")
:format(tostring(title.suicuneX), tostring(title.suicuneY)))
for i = 1, 4 do
U.shot(game, ("%s/title-entrance-%d.png"):format(out, i))
U.wait(4)
end
for _ = 1, 90 do
if title.entranceScx == 0 then break end
U.wait(1)
end
assert(title.entranceScx == 0, "the entrance never landed")
assert(title.gemY == title.gemRestY,
("gem y %s did not land at %s with the entrance")
:format(tostring(title.gemY), tostring(title.gemRestY)))
U.wait(2)
for i = 1, 5 do
U.shot(game, ("%s/title-suicune-%d.png"):format(out, i))
U.wait(6)
end
if love.window and love.window.setMode then
for _, shape in ipairs({ { 1280, 720 }, { 1920, 500 }, { 480, 800 } }) do
love.window.setMode(shape[1], shape[2], { resizable = true })
U.wait(3)
U.shot(game, ("%s/title-wide-%dx%d.png"):format(out, shape[1], shape[2]))
end
love.window.setMode(1280, 720, { resizable = true })
end
local intro3 = CrystalIntro.new(game, {})
game.stack:clear()
game.stack:push(intro3)
local function runTo(target, extra)
for _ = 1, LIMIT do
if intro3.scene >= target then break end
U.wait(5)
end
assert(intro3.scene >= target,
"widescreen pass never reached scene " .. target)
U.wait(extra)
end
runTo(4, 40)
U.shot(game, out .. "/intro-wide-scene04.png")
runTo(10, 60)
U.shot(game, out .. "/intro-wide-scene10.png")
runTo(18, 20)
U.shot(game, out .. "/intro-wide-scene18.png")
intro3:skip()
U.log(("PASS crystal intro + title shots in %s"):format(out))
end
+278
View File
@@ -0,0 +1,278 @@
-- The Crystal story specials, driven in a real game.
--
-- POKEPORT_IDENTITY=unitb-crystal POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unitb \
-- POKEPORT_DRIVER=tests/drivers/crystal_story_shots.lua love .
--
-- Four sections, each entered by putting the player on the real map and
-- letting the extracted script run:
-- A TIN_TOWER_1F the Suicune confrontation, and whether it flees
-- B ILEX_FOREST the GS Ball shrine, Celebi, and CheckCaughtCelebi
-- C DRAGON_SHRINE GiveDratini's Extremespeed moveset
-- D DAY_CARE GiveOddEgg
local U = require("tests.drivers.util")
local BattleState = require("src.ui.gen2.BattleState")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local Roamers = require("src.core.gen2.Roamers")
local Save = require("src.core.gen2.Save")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-story"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function top() return game.stack:top() end
local function battleState()
local st = top()
if st ~= nil and getmetatable(st) == BattleState then return st end
return nil
end
local function inBattle()
local st = battleState()
return st and st.battle or nil
end
local function waitFor(pred, frames)
for _ = 1, frames or 1200 do
if pred() then return true end
U.wait(1)
end
return false
end
local world
-- Mash A until the script is finished and the stack is bare again. The
-- naming screen gets B: `givepoke` ends in Specials.askNickname
-- (src/script/gen2/Specials.lua:382) and mashing A there types letters
-- forever instead of leaving.
-- The naming screen has no B exit: crystal_boot_smoke.lua leaves it by
-- putting the cursor on the bottom row's confirm cell. Confirming a blank
-- name is the cart's own "no nickname" (home/string.asm:6 _InitString).
local function mash()
local st = top()
if getmetatable(st) == NamingScreen then
st.row, st.col = st:bottomRow(), 6
end
tap("a", 2)
end
local function settle(frames)
for _ = 1, frames or 3000 do
if not world:busy() and top() == nil then return true end
mash()
end
local meta, name = getmetatable(top()), "?"
for id, mod in pairs(package.loaded) do
if mod == meta then name = id end
end
say((" stuck on %s (phase=%s) busy=%s"):format(name,
tostring(top() and top().phase), tostring(world:busy())))
return false
end
-- Stand one cell below the object carrying `scriptKey`, facing it.
local function standBelow(mapId, scriptKey)
world:setMap(mapId, 1, 1, "down")
U.wait(10)
local tx, ty
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.scriptKey == scriptKey then
tx, ty = npc.cellX, npc.cellY
end
end
if not tx then return false end
world:setMap(mapId, tx, ty + 1, "up")
U.wait(30)
return world.player.cellX == tx and world.player.cellY == ty + 1
end
local function moveIds(mon)
local list = {}
for _, move in ipairs((mon and mon.moves) or {}) do
list[#list + 1] = move.id
end
return table.concat(list, ",")
end
U.wait(60)
world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local save = game.save
save.player = save.player or {}
save.player.name = save.player.name or "CHRIS"
save.player.id = save.player.id or 30000
save.inventory = save.inventory or {}
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
-- ---- A TIN_TOWER_1F ----------------------------------------------------
-- ../pokecrystal/maps/TinTower1F.asm:84 TinTower1FSuicuneBattleScript, run
-- from the SCENE_TINTOWER1F_SUICUNE_BATTLE scene script at :22.
say("A Tin Tower")
say(" crystal AlwaysFleeMons SUICUNE = "
.. tostring(Roamers.alwaysFleeMons("crystal").SUICUNE)
.. ", gold = " .. tostring(Roamers.alwaysFleeMons("gold").SUICUNE))
world.mapScenes.TIN_TOWER_1F = 0
world:setMap("TIN_TOWER_1F", 9, 14, "up")
U.wait(20)
U.shot(game, out .. "/a1-tintower-enter.png")
ok(waitFor(function() return world:busy() end, 600),
"the scene script started on map entry")
U.wait(120)
U.shot(game, out .. "/a2-beasts.png")
ok(waitFor(function() return inBattle() ~= nil end, 3000),
"the Tin Tower scene reached a battle")
local battle = inBattle()
if battle then
U.wait(90)
U.shot(game, out .. "/a3-suicune-battle.png")
ok(battle.enemy and battle.enemy.species == "SUICUNE",
"the wild mon is SUICUNE (got " ..
tostring(battle.enemy and battle.enemy.species) .. ")")
ok((battle.enemy and battle.enemy.level) == 40, "at level 40")
-- ../pokecrystal/engine/battle/core.asm:759 TryEnemyFlee: with Suicune off
-- AlwaysFleeMons it has to survive the two random gates as well.
local fled = 0
for _ = 1, 500 do
if battle:tryEnemyFlee() then fled = fled + 1 end
end
ok(fled == 0, "Suicune never flees in 500 rolls (fled " .. fled .. ")")
ok(battle.outcome == nil, "and the battle is still live")
battle.outcome = "run"
local st = battleState()
if st then st:finishBattle() end
end
ok(settle(2000), "the Tin Tower script ran to its end")
-- ---- B ILEX_FOREST -----------------------------------------------------
-- ../pokecrystal/maps/IlexForest.asm:429 IlexForestShrineScript, a BGEVENT_UP
-- at (8,22). EVENT_FOREST_IS_RESTLESS and the GS Ball are the two gates; on
-- a retail cart neither can be reached, so both are set here the way the
-- Virtual Console wrapper sets sGSBallFlag
-- (../pokecrystal/engine/menus/save.asm:168).
say("B Ilex Forest shrine")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
Save.crystalState(save).gsBall = "have"
save.inventory.GS_BALL = 1
save.inventory.MASTER_BALL = 5
world:setMap("ILEX_FOREST", 8, 23, "up")
U.wait(40)
world.events:set(192, true)
U.shot(game, out .. "/b1-shrine.png")
tap("a", 30)
U.shot(game, out .. "/b2-shrine-prompt.png")
-- The prompt is the script's own yesorno; A takes YES.
ok(waitFor(function()
if inBattle() then return true end
tap("a", 3)
return false
end, 400), "the shrine script reached a battle")
local celebi = inBattle()
if celebi then
U.wait(90)
U.shot(game, out .. "/b3-celebi.png")
ok(celebi.enemy and celebi.enemy.species == "CELEBI",
"the wild mon is CELEBI (got " ..
tostring(celebi.enemy and celebi.enemy.species) .. ")")
ok(celebi.battleType == 11,
"the battle carries BATTLETYPE_CELEBI (got "
.. tostring(celebi.battleType) .. ")")
-- Catch it with a MASTER BALL so CheckCaughtCelebi has something to read.
local thrown = false
for _ = 1, 900 do
local st = battleState()
if not st then break end
if not thrown and st.phase == "menu" then
st:useItem("MASTER_BALL")
thrown = true
U.wait(60)
U.shot(game, out .. "/b4-masterball.png")
end
mash()
end
end
ok(settle(2000), "the shrine script ran to its end")
U.shot(game, out .. "/b5-after-celebi.png")
local caught = false
for _, mon in ipairs(save.party) do
if mon.species == "CELEBI" then caught = true end
end
ok(caught, "Celebi is in the party")
ok(Save.crystalState(save).celebiCaught == true,
"CheckCaughtCelebi recorded the catch")
ok((save.inventory.GS_BALL or 0) == 0, "the GS Ball was taken")
-- ---- C DRAGON_SHRINE ---------------------------------------------------
-- ../pokecrystal/maps/DragonShrine.asm:192 DragonShrineElder1Script, whose
-- .GiveDratini arm (:208) is `givepoke DRATINI, 15 / checkevent
-- EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG / special GiveDratini`.
say("C Dragon Shrine")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
-- ../pokecrystal/maps/DragonShrine.asm:10 SCENE_DRAGONSHRINE_NOOP; scene 0 is
-- the Dragon Master quiz cutscene, which is not what this section measures.
world.mapScenes.DRAGON_SHRINE = 1
ok(standBelow("DRAGON_SHRINE", "63:51a5"), "found the shrine elder")
U.shot(game, out .. "/c1-shrine.png")
tap("a", 30)
U.shot(game, out .. "/c2-elder.png")
ok(settle(2000), "the elder's script ran to its end")
U.shot(game, out .. "/c3-dratini.png")
local dratini
for _, mon in ipairs(save.party) do
if mon.species == "DRATINI" then dratini = mon end
end
ok(dratini ~= nil, "the elder handed over a DRATINI")
if dratini then
say(" moves = " .. moveIds(dratini))
ok(moveIds(dratini) == "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED",
"with .Moveset0, Extremespeed and all")
ok(dratini.moves[4].pp == 5, "Extremespeed arrives with 5 PP")
end
-- ---- D DAY_CARE --------------------------------------------------------
-- ../pokecrystal/maps/DayCare.asm:23 DayCareManScript_Inside, whose
-- `special GiveOddEgg` is at :33.
say("D Day Care")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
save.inventory.EGG_TICKET = 1
ok(standBelow("DAY_CARE", "18:6f8f"), "found the Day-Care Man")
U.shot(game, out .. "/d1-daycare.png")
tap("a", 30)
U.shot(game, out .. "/d2-gramps.png")
ok(settle(2000), "the Day-Care Man's script ran to its end")
U.shot(game, out .. "/d3-oddegg.png")
local egg = save.party[2]
ok(egg ~= nil and egg.isEgg == true, "an EGG joined the party")
if egg then
say((" %s ot=%s otId=%s eggSteps=%s moves=%s"):format(
tostring(egg.species), tostring(egg.ot), tostring(egg.otId),
tostring(egg.eggSteps), moveIds(egg)))
ok(egg.ot == "ODD", "with OT ODD")
ok(egg.eggSteps == 20, "and 20 hatch cycles")
end
ok((save.inventory.EGG_TICKET or 0) == 0, "the EGG TICKET was tossed")
say(fails == 0 and ("PASS crystal story in " .. out)
or ("FAIL " .. fails .. " checks, shots in " .. out))
love.event.quit(fails == 0 and 0 or 1)
end
+277
View File
@@ -0,0 +1,277 @@
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
-- opponent draw, the walk-in, the battle, and the counter the room script
-- reads back.
--
-- POKEPORT_IDENTITY=f1-tower POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
-- POKEPORT_DRIVER=tests/drivers/crystal_tower_battle_f1.lua love .
--
-- src/world/gen2/World.lua has no startTowerBattle / setObjectSprite hook and
-- World:startBattle does not forward `battleTower` into Battle.new, so the
-- three seams are shimmed here for the length of the run. The bodies are
-- exactly what belongs in World:specialHooks and World:startBattle.
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local World = require("src.world.gen2.World")
-- The world exists before a POKEPORT_DRIVER chunk loads (main.lua:426-438),
-- so the seams go on the live instance.
local function shimWorldSeams(world)
local towerBattle = false
local baseNew = Battle.new
Battle.new = function(opts)
if towerBattle then opts.battleTower = true end
return baseNew(opts)
end
local baseStartBattle = World.startBattle
world.startBattle = function(self, opts, onDone)
towerBattle = (opts and opts.battleTower) and true or false
local started = baseStartBattle(self, opts, onDone)
towerBattle = false
return started
end
local hooks = world.vm.specials
hooks.startTowerBattle = function(trainer, onDone)
return world:startBattle({ trainer = trainer, battleTower = true }, onDone)
end
hooks.setObjectSprite = function(objectId, spriteName)
local index = (objectId or 0) - 1
local def = world.map and world.map.def
local obj = def and def.objects and def.objects[index]
local sheet = world.sprites and world.sprites[spriteName]
if not (obj and sheet) then return false end
obj.sprite = spriteName
local npc = world:objectEntity(objectId)
if npc and npc:setSpriteDef(sheet) then world:applySpritePalette(npc) end
world:rebuildPeople({ seamless = true })
return true
end
end
-- ---- the run --------------------------------------------------------------
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
shimWorldSeams(world)
ok(type(world.vm.specials.startTowerBattle) == "function",
"the shimmed startTowerBattle hook reached the VM")
ok(type(world.vm.specials.setObjectSprite) == "function",
"and so did setObjectSprite")
local roster = BattleTower.roster(game.data)
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
if roster then
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
roster.sampleTrainers))
end
-- Three legal mons over the L10 room, each with ONE strong move so a blind
-- A-press run cannot pick a status move for a hundred turns.
local party = {}
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
{ "FERALIGATR", 60, "SURF", "BERRY" },
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
local def = game.data.moves[row[3]]
local mon = Mon.new(game.data, row[1], row[2], {
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
})
mon.item = row[4]
party[#party + 1] = mon
end
game.save.party = party
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
-- the L10 one (battle_tower.asm:1129-1141).
local tower = BattleTower.state(game.save)
tower.levelGroup = 1
tower.streak = 0
tower.trainers = {}
tower.challenge = BattleTower.NO_CHALLENGE
while game.stack:top() do game.stack:pop() end
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
U.wait(30)
shot("room-entered")
-- The scene script walks the player in, draws the opponent and starts the
-- battle; nothing here presses anything until a text box wants it.
local sawOpponent, sawBattle = false, false
for _ = 1, 900 do
local vm = world.vm
if vm and vm.btOpponent and not sawOpponent then
sawOpponent = true
say(("opponent: %s %s (row %d), sprite %s, group %d")
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
vm.btOpponent.group))
for slot, mon in ipairs(vm.btOpponent.rows) do
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
tostring(mon.item)))
end
U.wait(20)
shot("opponent-walked-in")
end
if world.battleActive then
sawBattle = true
break
end
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
end
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
local screen, battle = nil, nil
if sawBattle then
U.wait(90)
shot("battle-open")
screen = game.stack:top()
battle = screen and screen.battle
ok(battle ~= nil, "the battle screen owns a Battle")
if battle then
ok(battle.inBattleTowerBattle == true,
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
.. " L" .. tostring(battle.enemy and battle.enemy.level))
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
"with a three-mon Tower party")
end
end
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
-- move with PP left.
local function hardestMove(mon)
local best, bestPower = 1, -1
for index, move in ipairs((mon and mon.moves) or {}) do
local def = game.data.moves and game.data.moves[move.id]
local power = (def and def.power) or 0
if (move.pp or 0) > 0 and power > bestPower then
best, bestPower = index, power
end
end
return best
end
-- PP topped up between turns: an unattended run otherwise Struggles itself
-- to death long before the third opponent mon is down.
local function refill(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
move.pp = move.maxPp or move.pp
end
end
local menus = 0
for _ = 1, 900 do
if not world.battleActive or (battle and battle.over) then break end
local phase = screen and screen.phase
if phase == "menu" then
menus = menus + 1
if menus == 1 then shot("battle-menu") end
refill(battle and battle.player)
screen.menuIndex = 1
tap("a", 4)
elseif phase == "moves" then
screen.menuIndex = hardestMove(battle and battle.player)
tap("a", 4)
elseif phase == "submenu" then
-- A forced switch cannot be cancelled, so walk off the fainted lead.
tap("down", 3)
tap("a", 4)
else
tap("a", 3)
end
end
if battle then
say("battle over=" .. tostring(battle.over)
.. " outcome=" .. tostring(battle.outcome)
.. " phase=" .. tostring(screen and screen.phase))
ok(battle.over, "the battle resolved")
ok(battle.outcome == "win", "and the L60 party won it")
shot("battle-end")
end
for _ = 1, 200 do
if not world.battleActive then break end
tap("a", 3)
end
ok(not world.battleActive, "the battle screen came down")
U.wait(30)
shot("after-battle")
local vm = world.vm
local state = BattleTower.state(game.save)
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
tostring(vm and vm.stringBuffer)))
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
"and armed sBattleTowerChallengeState")
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
ok(state.prevTeams and #state.prevTeams.prev == 3,
"and sBTMonPrevTrainer recorded the team")
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
-- second draw all follow on their own.
local pages, secondDraw = {}, nil
for _ = 1, 400 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
if #pages <= 6 then shot("page-" .. #pages) end
end
if world.battleActive then
secondDraw = world.vm and world.vm.btOpponent
break
end
if world.choicebox then tap("a", 4) else tap("a", 3) end
end
say("pages: " .. table.concat(pages, " | "))
local joined = table.concat(pages, " | ")
ok(joined:find("healed to full health", 1, true) ~= nil
or joined:find("Next up", 1, true) ~= nil,
"the room script carried on past the battle")
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
or joined:find("no.2", 1, true) ~= nil,
"and wStringBuffer3 named the SECOND opponent")
if world.battleActive then
U.wait(60)
shot("second-battle")
ok(true, "the loop came back round for opponent two")
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
end
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+182
View File
@@ -0,0 +1,182 @@
-- The four Ruins of Alph secret chambers, opened in a real game.
--
-- POKEPORT_IDENTITY=f3-crystal POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/f3 \
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_chambers.lua love .
local U = require("tests.drivers.util")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:25 closed, :42 open;
-- ../pokecrystal/engine/overworld/scripting.asm:2147 halves them to block 2,0.
local WALL_BLOCK = 3
local WALL_SHUT, WALL_OPEN = 0x2e, 0x30
local CHAMBERS = {
{
key = "OMANYTE", word = "WATER",
-- ../pokecrystal/engine/events/unown_walls.asm:25 CheckItem, :38 MON_ITEM
shut = function(game)
game.save.inventory = { FIRE_STONE = 1 }
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
{ item = "FIRE_STONE" }) }
end,
arm = function(game)
game.save.inventory = {}
game.save.party = {
Mon.new(game.data, "TYPHLOSION", 40),
Mon.new(game.data, "LANTURN", 30, { item = "WATER_STONE" }),
}
end,
armed = "a WATER STONE held by the last party mon",
},
{
key = "HO_OH", word = "HO-OH",
-- ../pokecrystal/engine/events/unown_walls.asm:2 wPartySpecies[0].
shut = function(game)
game.save.inventory = {}
game.save.party = {
Mon.new(game.data, "TYPHLOSION", 40),
Mon.new(game.data, "HO_OH", 70),
}
end,
arm = function(game)
game.save.party = {
Mon.new(game.data, "HO_OH", 70),
Mon.new(game.data, "TYPHLOSION", 40),
}
end,
armed = "HO-OH moved into the FIRST party slot",
},
{
key = "KABUTO", word = "ESCAPE",
shut = function(game)
game.save.inventory = { ESCAPE_ROPE = 1 }
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
end,
-- ../pokecrystal/engine/events/overworld.asm:809 farcall
-- SpecialKabutoChamber, off EscapeRopeOrDig's rope arm.
arm = function(_, world)
UnownWords.kabutoChamber(world.events, world.map and world.map.id)
end,
armed = "SpecialKabutoChamber, as the escape rope calls it",
},
{
key = "AERODACTYL", word = "LIGHT",
shut = function(game)
game.save.inventory = {}
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
end,
-- ../pokecrystal/engine/events/overworld.asm:285 farcall
-- SpecialAerodactylChamber, inside FlashFunction.CheckUseFlash.
arm = function(_, world)
UnownWords.aerodactylChamber(world.events, world.map and world.map.id)
end,
armed = "SpecialAerodactylChamber, as FLASH calls it",
},
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-chambers"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function wordScreen()
local state = game.stack:top()
return (state and getmetatable(state) == UnownWords) and state or nil
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "CHRIS"
game.save.player.id = game.save.player.id or 30000
local function enter(mapId)
world:setMap("RUINS_OF_ALPH_OUTSIDE", 8, 15, "down")
U.wait(20)
world.mapScenes[mapId] = 0
world:setMap(mapId, 3, 1, "up")
for _ = 1, 400 do
U.wait(2)
if not world:busy() and not world.pendingSceneScript then break end
end
U.wait(30)
end
local function blockNow()
local blocks = world.map and world.map.def and world.map.def.blocks
return blocks and blocks[WALL_BLOCK]
end
for index, chamber in ipairs(CHAMBERS) do
local mapId = UnownWords.CHAMBER_MAPS[chamber.key]
local flag = UnownWords.WALL_OPENED[chamber.key]
say(("%d %s (flag %d)"):format(index, mapId, flag))
world.events:set(flag, false)
chamber.shut(game, world)
enter(mapId)
ok(world.map and world.map.id == mapId, " stood in the chamber")
ok(not world.events:get(flag), " the wall flag is still clear")
ok(blockNow() == WALL_SHUT,
(" and the hidden-doors callback drew the closed wall (%s)")
:format(tostring(blockNow())))
U.shot(game, ("%s/%d-%s-shut.png"):format(out, index,
chamber.key:lower()))
chamber.arm(game, world)
say(" armed: " .. chamber.armed)
world.mapScenes[mapId] = 0
enter(mapId)
ok(world.events:get(flag), " the wall flag is set now")
ok(blockNow() == WALL_OPEN,
(" and the wall-open script rewrote the block (%s)")
:format(tostring(blockNow())))
U.shot(game, ("%s/%d-%s-open.png"):format(out, index,
chamber.key:lower()))
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:82
-- RuinsOfAlphOmanyteChamberWallPatternLeft
local screen
for _ = 1, 200 do
screen = wordScreen()
if screen then break end
tap("a", 2)
end
ok(screen ~= nil, " the wall pattern reached DisplayUnownWords")
if screen then
U.wait(20)
ok(screen.wall and screen.wall.word == chamber.word,
(" showing %s (got %s)"):format(chamber.word,
tostring(screen.wall and screen.wall.word)))
U.shot(game, ("%s/%d-%s-word.png"):format(out, index,
chamber.key:lower()))
tap("a", 10)
U.wait(20)
end
end
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
U.wait(10)
love.event.quit(fails == 0 and 0 or 1)
end
+104
View File
@@ -0,0 +1,104 @@
-- The Ruins of Alph wall words and word rooms, driven in a real game.
--
-- POKEPORT_IDENTITY=unit-e-unown POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unit-e \
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_words_shots.lua love .
local U = require("tests.drivers.util")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
-- maps/RuinsOfAlphKabutoChamber.asm:272 `bg_event 4, 0, BGEVENT_UP`
local CHAMBERS = {
{ map = "RUINS_OF_ALPH_KABUTO_CHAMBER", word = "ESCAPE" },
{ map = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", word = "LIGHT" },
{ map = "RUINS_OF_ALPH_OMANYTE_CHAMBER", word = "WATER" },
{ map = "RUINS_OF_ALPH_HO_OH_CHAMBER", word = "HO-OH" },
}
local WORD_ROOMS = {
{ map = "RUINS_OF_ALPH_KABUTO_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_HO_OH_WORD_ROOM", x = 9, y = 6 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-words"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function top() return game.stack:top() end
local function wordScreen()
local state = top()
return (state and getmetatable(state) == UnownWords) and state or nil
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local save = game.save
save.player = save.player or {}
save.player.name = save.player.name or "CHRIS"
save.player.id = save.player.id or 30000
save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
for index, chamber in ipairs(CHAMBERS) do
say("A" .. index .. " " .. chamber.map)
world:setMap(chamber.map, 4, 1, "up")
U.wait(30)
U.shot(game, ("%s/a%d-%s-chamber.png"):format(
out, index, chamber.word:lower()))
local screen
for _ = 1, 300 do
screen = wordScreen()
if screen then break end
if not world:busy() then tap("a", 2) else tap("a", 2) end
end
ok(screen ~= nil, chamber.map .. " reached DisplayUnownWords")
if screen then
U.wait(20)
ok(screen.wall and screen.wall.word == chamber.word,
(" showing %s (got %s)"):format(chamber.word,
tostring(screen.wall and screen.wall.word)))
ok(#screen.squares == #chamber.word,
(" %d letter squares"):format(#screen.squares))
U.shot(game, ("%s/a%d-%s-word.png"):format(
out, index, chamber.word:lower()))
tap("a", 10)
ok(wordScreen() == nil, " A closes the box")
U.wait(20)
end
end
for index, room in ipairs(WORD_ROOMS) do
say("B" .. index .. " " .. room.map)
world:setMap(room.map, room.x, room.y, "up")
U.wait(40)
ok(world.map and world.map.id == room.map, " stood in " .. room.map)
U.shot(game, ("%s/b%d-%s.png"):format(out, index,
room.map:lower():gsub("ruins_of_alph_", "")))
end
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
U.wait(10)
love.event.quit(fails == 0 and 0 or 1)
end
+119
View File
@@ -0,0 +1,119 @@
-- The two chamber walls the FIELD MOVES open, driven through the production
-- call sites rather than by calling the routines directly.
--
-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash
-- (badge FIRST, then SpecialAerodactylChamber) and :808-813 EscapeRopeOrDig's
-- `.escaperope` arm.
--
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_DRIVER=tests/drivers/gate_chamber_fieldmoves.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
return function(game)
local fails = 0
local function say(line) print("[driver] " .. line); io.stdout:flush() end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
local function clearText()
for _ = 1, 200 do
if not world:busy() then return true end
tap("a", 3)
end
return false
end
local function enter(mapId)
while game.stack:top() do game.stack:pop() end
assert(world:setMap(mapId, 3, 7, "up"), "no " .. mapId .. " in this cache")
for _ = 1, 240 do
if not world:busy() then break end
U.wait(2)
end
U.wait(10)
end
-- ---- the escape rope, in the Kabuto chamber ------------------------------
say("--- KABUTO, through World:useEscapeRope()")
enter(UnownWords.CHAMBER_MAPS.KABUTO)
ok(not UnownWords.wallOpened(world.events, "KABUTO"),
"the Kabuto wall flag starts clear")
game.save.inventory = { ESCAPE_ROPE = 1 }
-- wDigWarpNumber / wBackupMapGroup: the cave's own entrance, which
-- ../pokecrystal/engine/events/overworld.asm:795-798 copies into wNextWarp.
world.backupWarp = { map = "RUINS_OF_ALPH_OUTSIDE", warp = 1 }
clearText()
local rope = world:useEscapeRope("ESCAPE_ROPE")
say("escape rope result: " .. tostring(rope))
ok(rope == "escape_rope", "the rope was used")
ok(UnownWords.wallOpened(world.events, "KABUTO"),
"and SpecialKabutoChamber set the wall flag")
-- The rope must not open a wall anywhere else.
world.queuedFieldMove = nil
enter("RUINS_OF_ALPH_OUTSIDE")
clearText()
local before = UnownWords.wallOpened(world.events, "OMANYTE")
game.save.inventory = { ESCAPE_ROPE = 1 }
world:useEscapeRope("ESCAPE_ROPE")
ok(UnownWords.wallOpened(world.events, "OMANYTE") == before,
"a rope used outside a chamber opens nothing")
-- ---- FLASH, in the Aerodactyl chamber ------------------------------------
say("--- AERODACTYL, through World:useFieldMove(\"FLASH\")")
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
ok(world.map.id == UnownWords.CHAMBER_MAPS.AERODACTYL, "in the chamber")
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
"the wall flag starts clear")
local flash = game.data.moves.FLASH
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
{ moves = { { id = "FLASH", pp = flash.pp, maxPp = flash.pp } } }) }
-- :281-283, the ZEPHYRBADGE gate that runs BEFORE the special.
game.save.player = game.save.player or {}
game.save.player.badges = {}
local refused = world:useFieldMove("FLASH", game.save.party[1])
ok(refused and refused.ok ~= true, "no ZEPHYRBADGE: FLASH is refused")
ok(refused and refused.badge == "ZEPHYR", "on the badge, not on the map")
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
"and the badgeless press did NOT open the wall")
-- The refusal opened a text box; the world is busy until it is dismissed.
clearText()
game.save.player.badges = { ZEPHYR = true }
local used = world:useFieldMove("FLASH", game.save.party[1])
say("flash result: ok=" .. tostring(used and used.ok)
.. " action=" .. tostring(used and used.action))
ok(used and used.ok == true,
"with the badge FLASH is allowed in a chamber that is not a dark cave")
ok(UnownWords.wallOpened(world.events, "AERODACTYL"),
"and SpecialAerodactylChamber set the wall flag")
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
U.wait(40)
local _, block = world:blockIndexAt(3, 5)
say("aerodactyl block under the wall: " .. tostring(block))
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp")
.. "/01-aerodactyl-flash-open.png")
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+80
View File
@@ -0,0 +1,80 @@
-- Gate check: the Crystal wave left Gold alone. No Battle Tower, no Buena's
-- Blue Card, no Ruins of Alph secret chambers, and the three beasts roam.
--
-- POKEPORT_GAME=gold POKEPORT_VERSION=gold \
-- POKEPORT_DRIVER=tests/drivers/gate_gold_untouched.lua love .
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local Roamers = require("src.core.gen2.Roamers")
return function(game)
local fails = 0
local function say(line) print("[driver] " .. line); io.stdout:flush() end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
assert(world and world.map, "gold world did not boot")
say("version=" .. tostring(game.version) .. " map=" .. tostring(world.map.id))
local order = {}
for _, name in ipairs((world.constants or {}).specialOrder or {}) do
order[name] = true
end
ok(next(order) ~= nil, "the Gold cache names its specials")
for _, name in ipairs({ "BattleTowerBattle", "BattleTowerAction",
"BattleTowerRoomMenu", "LoadOpponentTrainerAndPokemonWithOTSprite",
"BuenasPassword", "BuenaPrize", "AskRememberPassword",
"OmanyteChamber", "HoOhChamber", "CelebiShrineEvent",
"SampleKenjiBreakCountdown", "MoveTutor", "PokeSeer" }) do
ok(not order[name], "Gold has no " .. name .. " special")
end
ok(world.maps.BATTLE_TOWER_1F == nil, "no BATTLE_TOWER_1F map")
ok(world.maps.BATTLE_TOWER_BATTLE_ROOM == nil, "no tower battle room")
ok((world.trainers or {}).battleTower == nil,
"trainers.lua carries no battleTower roster")
ok((world.eventTables or {}).unownWalls == nil,
"events.lua carries no unownWalls table")
-- ../pokecrystal/constants/event_flags.asm:486-489 are Crystal-only.
for _, flag in ipairs({ 806, 807, 808, 809 }) do
ok(not world.events:get(flag), "wall-opened flag " .. flag .. " is clear")
end
-- ../pokecrystal/constants/script_constants.asm:69-74, Crystal-only VARs.
for _, id in ipairs({ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a }) do
ok(world:readVar(id) == 0,
("VAR $%02x reads 0 on Gold"):format(id))
end
-- data/wild/roammon_maps.asm: RAIKOU, ENTEI and SUICUNE, all three.
local roster = Roamers.roster(world.encounters)
say("roamer roster: " .. #roster .. " rows")
for _, row in ipairs(roster) do
say((" %s L%s start=%s"):format(tostring(row.species),
tostring(row.level), tostring(row.map)))
end
ok(#roster == 3, "three beasts in the roster")
local list = Roamers.init(game.save, { encounters = world.encounters, force = true })
local live = 0
for _, slot in ipairs(list or {}) do
if Roamers.active(slot) then live = live + 1 end
end
ok(live == 3, "all three are active after InitRoamMons")
-- pokegold/engine/battle/core.asm:3476-3479: TRAP and FORCESHINY only.
ok(Battle.noEscapeBattleType({ battleType = 9 }) == true, "TRAP: no escape")
ok(Battle.noEscapeBattleType({ battleType = 7 }) == true,
"FORCESHINY: no escape")
ok(Battle.noEscapeBattleType({ battleType = 10 }) == false,
"FORCEITEM: Lugia and Ho-Oh can still be run from on Gold")
ok(Battle.noEscapeBattleType({ battleType = 8 }) == false, "TREE: escapable")
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+236
View File
@@ -0,0 +1,236 @@
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
-- opponent draw, the walk-in, the battle, and the counter the room script
-- reads back.
--
-- Nothing is shimmed: the startTowerBattle / setObjectSprite hooks and the
-- battleTower field all come from World itself.
--
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
-- POKEPORT_DRIVER=tests/drivers/gate_tower_noshim.lua love .
local U = require("tests.drivers.util")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
-- ---- the run --------------------------------------------------------------
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
ok(type(world.vm.specials.startTowerBattle) == "function",
"World:specialHooks supplies startTowerBattle, unshimmed")
ok(type(world.vm.specials.setObjectSprite) == "function",
"World:specialHooks supplies setObjectSprite, unshimmed")
local roster = BattleTower.roster(game.data)
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
if roster then
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
roster.sampleTrainers))
end
-- Three legal mons over the L10 room, each with ONE strong move so a blind
-- A-press run cannot pick a status move for a hundred turns.
local party = {}
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
{ "FERALIGATR", 60, "SURF", "BERRY" },
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
local def = game.data.moves[row[3]]
local mon = Mon.new(game.data, row[1], row[2], {
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
})
mon.item = row[4]
party[#party + 1] = mon
end
game.save.party = party
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
-- the L10 one (battle_tower.asm:1129-1141).
local tower = BattleTower.state(game.save)
tower.levelGroup = 1
tower.streak = 0
tower.trainers = {}
tower.challenge = BattleTower.NO_CHALLENGE
while game.stack:top() do game.stack:pop() end
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
U.wait(30)
shot("room-entered")
-- The scene script walks the player in, draws the opponent and starts the
-- battle; nothing here presses anything until a text box wants it.
local sawOpponent, sawBattle = false, false
for _ = 1, 900 do
local vm = world.vm
if vm and vm.btOpponent and not sawOpponent then
sawOpponent = true
say(("opponent: %s %s (row %d), sprite %s, group %d")
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
vm.btOpponent.group))
for slot, mon in ipairs(vm.btOpponent.rows) do
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
tostring(mon.item)))
end
U.wait(20)
shot("opponent-walked-in")
end
if world.battleActive then
sawBattle = true
break
end
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
end
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
local screen, battle = nil, nil
if sawBattle then
U.wait(90)
shot("battle-open")
screen = game.stack:top()
battle = screen and screen.battle
ok(battle ~= nil, "the battle screen owns a Battle")
if battle then
ok(battle.inBattleTowerBattle == true,
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
.. " L" .. tostring(battle.enemy and battle.enemy.level))
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
"with a three-mon Tower party")
end
end
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
-- move with PP left.
local function hardestMove(mon)
local best, bestPower = 1, -1
for index, move in ipairs((mon and mon.moves) or {}) do
local def = game.data.moves and game.data.moves[move.id]
local power = (def and def.power) or 0
if (move.pp or 0) > 0 and power > bestPower then
best, bestPower = index, power
end
end
return best
end
-- PP topped up between turns: an unattended run otherwise Struggles itself
-- to death long before the third opponent mon is down.
local function refill(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
move.pp = move.maxPp or move.pp
end
end
local menus = 0
for _ = 1, 900 do
if not world.battleActive or (battle and battle.over) then break end
local phase = screen and screen.phase
if phase == "menu" then
menus = menus + 1
if menus == 1 then shot("battle-menu") end
refill(battle and battle.player)
screen.menuIndex = 1
tap("a", 4)
elseif phase == "moves" then
screen.menuIndex = hardestMove(battle and battle.player)
tap("a", 4)
elseif phase == "submenu" then
-- A forced switch cannot be cancelled, so walk off the fainted lead.
tap("down", 3)
tap("a", 4)
else
tap("a", 3)
end
end
if battle then
say("battle over=" .. tostring(battle.over)
.. " outcome=" .. tostring(battle.outcome)
.. " phase=" .. tostring(screen and screen.phase))
ok(battle.over, "the battle resolved")
ok(battle.outcome == "win", "and the L60 party won it")
shot("battle-end")
end
for _ = 1, 200 do
if not world.battleActive then break end
tap("a", 3)
end
ok(not world.battleActive, "the battle screen came down")
U.wait(30)
shot("after-battle")
local vm = world.vm
local state = BattleTower.state(game.save)
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
tostring(vm and vm.stringBuffer)))
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
"and armed sBattleTowerChallengeState")
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
ok(state.prevTeams and #state.prevTeams.prev == 3,
"and sBTMonPrevTrainer recorded the team")
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
-- second draw all follow on their own.
local pages, secondDraw = {}, nil
for _ = 1, 400 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
if #pages <= 6 then shot("page-" .. #pages) end
end
if world.battleActive then
secondDraw = world.vm and world.vm.btOpponent
break
end
if world.choicebox then tap("a", 4) else tap("a", 3) end
end
say("pages: " .. table.concat(pages, " | "))
local joined = table.concat(pages, " | ")
ok(joined:find("healed to full health", 1, true) ~= nil
or joined:find("Next up", 1, true) ~= nil,
"the room script carried on past the battle")
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
or joined:find("no.2", 1, true) ~= nil,
"and wStringBuffer3 named the SECOND opponent")
if world.battleActive then
U.wait(60)
shot("second-battle")
ok(true, "the loop came back round for opponent two")
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
end
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -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"
+218
View File
@@ -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()
+1
View File
@@ -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
+292
View File
@@ -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()
+4 -1
View File
@@ -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
+15 -3
View File
@@ -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")
+20 -12
View File
@@ -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)
+2 -2
View File
@@ -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")
+25
View File
@@ -37,6 +37,31 @@ eq(FieldMoves.BADGE_FLAG[26].store, "badges", "Johto badges go to player.badges"
eq(FieldMoves.BADGE_FLAG[34].store, "kantoBadges",
"ENGINE_BOULDERBADGE goes to player.kantoBadges")
-- pokecrystal/constants/engine_flags.asm:39 declares 162 flags to pokegold's
-- 93, moving the whole badge block up one.
do
local order = {}
for i = 1, 43 do order[i] = "ENGINE_UNRELATED" .. i end
order[10] = nil -- a const_skip hole must not truncate the map
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
order[27 + index] = "ENGINE_" .. name .. "BADGE"
end
for index, name in ipairs(FieldMoves.KANTO_BADGES) do
order[35 + index] = "ENGINE_" .. name .. "BADGE"
end
FieldMoves.bindEngineFlags(order)
eq(FieldMoves.BADGE_FLAG[27].name, "ZEPHYR", "crystal ZEPHYRBADGE is 27")
eq(FieldMoves.BADGE_FLAG[28].name, "HIVE", "crystal HIVEBADGE is 28")
eq(FieldMoves.BADGE_FLAG[34].name, "RISING", "crystal RISINGBADGE is 34")
eq(FieldMoves.BADGE_FLAG[35].name, "BOULDER", "crystal BOULDERBADGE is 35")
eq(FieldMoves.BADGE_FLAG[35].store, "kantoBadges",
"a renumbered Kanto badge still lands in kantoBadges")
eq(FieldMoves.BADGE_FLAG[26], nil, "Gold's ZEPHYR slot is vacated")
FieldMoves.bindEngineFlags(nil)
eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "no map falls back to Gold")
end
-- ---------------------------------------------------------------------------
-- The round trip: what a gym script writes is what a field move reads.
-- ---------------------------------------------------------------------------
+603
View File
@@ -0,0 +1,603 @@
-- The Battle Tower BATTLE: the roster tables, the opponent draw and the two
-- specials maps/BattleTowerBattleRoom.asm's loop is built out of.
--
-- luajit tests/gen2_battle_tower_battle_test.lua
--
-- ROM-free. The extractor half runs against a synthetic cartridge whose
-- tables are laid out byte for byte the way data/battle_tower/ assembles
-- them, and the rest against a fixture roster taken from
-- ../pokecrystal/data/battle_tower/parties.asm group 1.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle tower battle")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local RomExtractorGen2 = require("src.import.RomExtractorGen2")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
-- ---------------------------------------------------------------- fixtures
-- ../pokecrystal/data/pokemon/base_stats/*.asm, the five species group 1 of
-- BattleTowerMons opens with.
local POKEMON = {
JOLTEON = { name = "JOLTEON", index = 135, types = { "ELECTRIC" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 65, attack = 65, defense = 60, speed = 130,
specialAttack = 110, specialDefense = 95 } },
ESPEON = { name = "ESPEON", index = 196, types = { "PSYCHIC_TYPE" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 65, attack = 65, defense = 60, speed = 110,
specialAttack = 130, specialDefense = 95 } },
UMBREON = { name = "UMBREON", index = 197, types = { "DARK" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 95, attack = 65, defense = 110, speed = 65,
specialAttack = 60, specialDefense = 130 } },
WOBBUFFET = { name = "WOBBUFFET", index = 202, types = { "PSYCHIC_TYPE" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 190, attack = 33, defense = 58, speed = 33,
specialAttack = 33, specialDefense = 58 } },
KANGASKHAN = { name = "KANGASKHAN", index = 115, types = { "NORMAL" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 105, attack = 95, defense = 80, speed = 90,
specialAttack = 40, specialDefense = 80 } },
}
local MOVES = {
THUNDERBOLT = { pp = 15 }, HYPER_BEAM = { pp = 5 }, SHADOW_BALL = { pp = 15 },
ROAR = { pp = 20 }, MUD_SLAP = { pp = 10 }, PSYCHIC_M = { pp = 10 },
PSYCH_UP = { pp = 10 }, TOXIC = { pp = 10 }, IRON_TAIL = { pp = 15 },
COUNTER = { pp = 20 }, MIRROR_COAT = { pp = 20 }, SAFEGUARD = { pp = 25 },
DESTINY_BOND = { pp = 5 }, REVERSAL = { pp = 15 }, EARTHQUAKE = { pp = 10 },
ATTRACT = { pp = 15 },
}
-- ../pokecrystal/data/battle_tower/parties.asm:4-140, transcribed field for
-- field: the DVs, the stat exp words, the PP bytes and the stats the cart
-- copies straight into wOTPartyMon.
local function row(species, item, moves, pp, dvs, statExp, stats)
return {
species = species, item = item, moves = moves, pp = pp,
level = 10, happiness = 100, experience = 1000, otId = 0,
dvs = { attack = dvs[1], defense = dvs[2], speed = dvs[3],
special = dvs[4] },
statExp = { hp = statExp[1], attack = statExp[2], defense = statExp[3],
speed = statExp[4], special = statExp[5] },
hp = stats[1], maxHp = stats[1],
stats = { hp = stats[1], attack = stats[2], defense = stats[3],
speed = stats[4], specialAttack = stats[5], specialDefense = stats[6] },
}
end
local GROUP1 = {
row("JOLTEON", "MIRACLEBERRY",
{ "THUNDERBOLT", "HYPER_BEAM", "SHADOW_BALL", "ROAR" }, { 15, 5, 15, 20 },
{ 13, 13, 11, 13 }, { 50000, 40000, 40000, 35000, 40000 },
{ 41, 25, 24, 37, 34, 31 }),
row("ESPEON", "LEFTOVERS",
{ "MUD_SLAP", "PSYCHIC_M", "PSYCH_UP", "TOXIC" }, { 10, 10, 10, 10 },
{ 14, 13, 15, 11 }, { 40000, 50000, 35000, 40000, 40000 },
{ 39, 26, 24, 35, 38, 31 }),
row("UMBREON", "GOLD_BERRY",
{ "SHADOW_BALL", "IRON_TAIL", "PSYCH_UP", "TOXIC" }, { 15, 15, 10, 10 },
{ 13, 11, 14, 15 }, { 40000, 40000, 45000, 50000, 40000 },
{ 46, 25, 34, 26, 25, 39 }),
row("WOBBUFFET", "FOCUS_BAND",
{ "COUNTER", "MIRROR_COAT", "SAFEGUARD", "DESTINY_BOND" },
{ 20, 20, 25, 5 }, { 7, 15, 13, 7 },
{ 50000, 50000, 50000, 50000, 50000 }, { 66, 18, 25, 19, 18, 23 }),
-- MIRACLEBERRY again, which is the item collision the draw refuses.
row("KANGASKHAN", "MIRACLEBERRY",
{ "REVERSAL", "HYPER_BEAM", "EARTHQUAKE", "ATTRACT" }, { 15, 5, 10, 15 },
{ 14, 15, 12, 15 }, { 40000, 30000, 40000, 30000, 30000 },
{ 47, 31, 29, 29, 20, 28 }),
}
-- ../pokecrystal/data/battle_tower/classes.asm:11-17
local ROSTER = {
partyLength = 3,
levelGroups = 2,
uniqueMon = #GROUP1,
uniqueTrainers = 4,
sampleTrainers = 4,
trainers = {
{ index = 0, name = "HANSON", class = 37, classId = "FISHER" },
{ index = 1, name = "SAWYER", class = 30, classId = "POKEMANIAC" },
{ index = 2, name = "MASUDA", class = 43, classId = "GUITARIST" },
{ index = 3, name = "NICKEL", class = 20, classId = "SCIENTIST" },
},
groups = { GROUP1, GROUP1 },
classSprites = {
FISHER = "SPRITE_FISHER", POKEMANIAC = "SPRITE_SUPER_NERD",
GUITARIST = "SPRITE_ROCKER", SCIENTIST = "SPRITE_SCIENTIST",
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
trainers = {
battleTower = ROSTER,
classes = {
FISHER = { index = 37, name = "FISHER", baseMoney = 40,
attributes = { 0, 0, 40, 1, 0, 0, 0 }, items = {} },
POKEMANIAC = { index = 30, name = "POKEMANIAC", baseMoney = 60,
attributes = { 0, 0, 60, 1, 0, 0, 0 }, items = {} },
GUITARIST = { index = 43, name = "GUITARIST", baseMoney = 36,
attributes = { 0, 0, 36, 1, 0, 0, 0 }, items = {} },
SCIENTIST = { index = 20, name = "SCIENTIST", baseMoney = 44,
attributes = { 0, 0, 44, 1, 0, 0, 0 }, items = {} },
},
},
}
local function crystalSave(levelGroup)
local save = Save.normalize({ version = "crystal", generation = 2 })
save.battleTower.levelGroup = levelGroup or 1
save.party = {}
return save
end
-- A roll that walks a canned list, so every draw below is pinned. Same
-- 1..n convention Specials.random uses.
local function rolls(list)
local at = 0
return function(n)
at = at + 1
local value = list[at] or 1
if value > n then value = ((value - 1) % n) + 1 end
return value
end
end
local function fakeVm(save, hooks)
local vm = {
scriptVar = 0,
mem = {},
stringBuffer = "",
specials = hooks or {},
}
vm.specials.save = vm.specials.save or function() return save end
vm.specials.data = vm.specials.data or function() return DATA end
vm.specials.party = vm.specials.party or function() return save.party end
function vm:setStringBuffer(value) self.stringBuffer = value or "" end
function vm:showRaw() end
return vm
end
-- =========================================== the extractor's roster decode
--
-- A synthetic cartridge: BattleTowerTrainers, BattleTowerMons and
-- BTTrainerClassSprites laid out exactly as data/battle_tower/classes.asm,
-- parties.asm and data/trainers/sprites.asm assemble, plus the two
-- `maskbits N / cp N / jr nc` pairs load_trainer.asm samples with.
do
local NAME_LENGTH, MON_NAME_LENGTH = 11, 11
local NICKNAMED = 48 + MON_NAME_LENGTH
local TRAINERS_AT, MONS_AT = 0x4100, 0x4100 + 3 * NAME_LENGTH
local SPRITES_AT, TR_SAMPLE_AT, MON_SAMPLE_AT = 0x5000, 0x5100, 0x5200
local bytes = {}
local function put(offset, list)
for index, value in ipairs(list) do bytes[offset + index] = value end
end
local function name(text, width)
local out = {}
for index = 1, width do
out[index] = (index <= #text) and text:byte(index) or 0x50
end
return out
end
-- charmap rows for the letters the three names use.
local charmap, letters = {}, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for index = 1, #letters do
charmap[tostring(0x80 + index - 1)] = letters:sub(index, index)
end
local function encode(text, width)
local out = {}
for index = 1, width do
if index <= #text then
out[index] = 0x80 + (text:byte(index) - 65)
else
out[index] = 0x50
end
end
return out
end
-- Three rows, the third exactly NAME_LENGTH - 1 long so the unterminated
-- name the `dname` macro leaves (classes.asm:16 ZABOROWSKI) is covered.
local rows = { { "ABLE", 1 }, { "BAKER", 2 }, { "CDEFGHIJKL", 3 } }
for index, entry in ipairs(rows) do
local at = TRAINERS_AT + (index - 1) * NAME_LENGTH
put(at, encode(entry[1], NAME_LENGTH - 1))
bytes[at + NAME_LENGTH] = entry[2]
end
-- One level group of two mons. Only the fields the reader names are set;
-- everything else stays 0, which is what ByteFill leaves.
local function monBytes(species, item, moves, pp, dv1, dv2, level, stats)
local raw = {}
for index = 1, NICKNAMED do raw[index] = 0 end
raw[1] = species
raw[2] = item
for slot = 1, 4 do raw[2 + slot] = moves[slot] or 0 end
raw[9], raw[10], raw[11] = 0, 0x03, 0xe8
raw[12], raw[13] = 0xc3, 0x50
raw[22], raw[23] = dv1, dv2
for slot = 1, 4 do raw[23 + slot] = pp[slot] or 0 end
raw[28] = 100
raw[32] = level
raw[35], raw[36] = 0, stats[1]
raw[37], raw[38] = 0, stats[1]
raw[39], raw[40] = 0, stats[2]
raw[41], raw[42] = 0, stats[3]
raw[43], raw[44] = 0, stats[4]
raw[45], raw[46] = 0, stats[5]
raw[47], raw[48] = 0, stats[6]
return raw
end
put(MONS_AT, monBytes(135, 109, { 85, 63, 247, 46 }, { 15, 5, 15, 20 },
0xdd, 0xbd, 10, { 41, 25, 24, 37, 34, 31 }))
put(MONS_AT + NICKNAMED, monBytes(196, 78, { 189, 94, 0, 0 }, { 10, 10, 0, 0 },
0xed, 0xfb, 10, { 39, 26, 24, 35, 38, 31 }))
put(SPRITES_AT, { 7, 9, 11 })
-- `and $03 / cp 3 / jr nc, .resample` and `and $01 / cp 2 / jr nc`.
put(TR_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47,
0xe6, 0x03, 0xfe, 0x03, 0x30, 0xf3 })
put(MON_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47,
0xe6, 0x01, 0xfe, 0x02, 0x30, 0xf3 })
local rom = {}
for index = 1, 0x8000 do rom[index] = string.char(bytes[index] or 0) end
local manifest = {
romSha1 = "",
charmap = charmap,
constants = {
trainerClassOrder = { "TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY",
"MYSTICALMAN" },
spriteOrder = { "SPRITE_A", "SPRITE_B", "SPRITE_C", "SPRITE_D",
"SPRITE_E", "SPRITE_F", "SPRITE_G", "SPRITE_H", "SPRITE_I",
"SPRITE_J", "SPRITE_K" },
moveOrder = {}, itemOrder = {}, speciesOrder = {},
},
symbols = {
BattleTowerTrainers = { 1, TRAINERS_AT },
BattleTowerMons = { 1, MONS_AT },
BTTrainerClassSprites = { 1, SPRITES_AT },
["LoadOpponentTrainerAndPokemon.resample"] = { 1, TR_SAMPLE_AT },
["LoadRandomBattleTowerMon.resample"] = { 1, MON_SAMPLE_AT },
},
}
manifest.constants.speciesOrder[135] = "JOLTEON"
manifest.constants.speciesOrder[196] = "ESPEON"
manifest.constants.itemOrder[109] = "MIRACLEBERRY"
manifest.constants.itemOrder[78] = "LEFTOVERS"
manifest.constants.moveOrder[85] = "THUNDERBOLT"
manifest.constants.moveOrder[63] = "HYPER_BEAM"
manifest.constants.moveOrder[247] = "SHADOW_BALL"
manifest.constants.moveOrder[46] = "ROAR"
manifest.constants.moveOrder[189] = "MUD_SLAP"
manifest.constants.moveOrder[94] = "PSYCHIC_M"
local ex = RomExtractorGen2.new(table.concat(rom), manifest, nil)
local read = ex:readBattleTowerRoster(manifest.constants, charmap)
check(read ~= nil, "the roster reads out of the cartridge")
eq(read.uniqueTrainers, 3, "the trainer count is the gap to BattleTowerMons")
eq(read.uniqueMon, 2, "NUM_UNIQUE_MON comes off the mon resample loop")
eq(read.sampleTrainers, 3,
"and the trainer ceiling off its own, which Crystal 1.0 gets wrong")
eq(read.trainers[1].name, "ABLE", "a terminated name stops at '@'")
eq(read.trainers[3].name, "CDEFGHIJKL",
"and a full-width one runs to NAME_LENGTH - 1 with no terminator")
eq(read.trainers[2].classId, "WHITNEY", "the class byte resolves to a name")
eq(read.classSprites.FALKNER, "SPRITE_G", "class 1 takes the first sprite")
eq(read.classSprites.BUGSY, "SPRITE_K", "class 3 the third")
eq(read.classSprites.MYSTICALMAN, nil,
"and MYSTICALMAN is off the end of BTTrainerClassSprites")
eq(#read.groups, 10, "ten level groups are always read")
local first = read.groups[1][1]
eq(first.species, "JOLTEON", "group 1 mon 1 species")
eq(first.item, "MIRACLEBERRY", "its held item")
eq(first.level, 10, "its level")
eq(first.happiness, 100, "its happiness")
eq(first.experience, 1000, "its three exp bytes, big-endian")
eq(first.statExp.hp, 50000, "its HP stat exp word")
eq(first.dvs.attack, 13, "attack DV out of the high nibble")
eq(first.dvs.defense, 13, "defense DV out of the low nibble")
eq(first.dvs.speed, 11, "speed DV")
eq(first.dvs.special, 13, "special DV")
eq(#first.moves, 4, "four moves")
eq(first.moves[3], "SHADOW_BALL", "move three")
eq(first.pp[2], 5, "and its PP byte")
eq(first.maxHp, 41, "the stored max HP")
eq(first.stats.speed, 37, "and the stored Speed")
local second = read.groups[1][2]
eq(#second.moves, 2, "a row with two NO_MOVE slots keeps two moves")
eq(#second.pp, 2, "and two PP bytes")
end
-- =========================== Mon.stats agrees with the cart's stored stats
--
-- ../pokecrystal/engine/pokemon/move_mon.asm CalcMonStats is what produced the
-- bytes in parties.asm, so the port's own formula has to land on them.
do
for _, mon in ipairs(GROUP1) do
local dvs = { attack = mon.dvs.attack, defense = mon.dvs.defense,
speed = mon.dvs.speed, special = mon.dvs.special }
local stats = Mon.stats(POKEMON[mon.species].baseStats, dvs, mon.level,
mon.statExp)
eq(stats.hp, mon.stats.hp, mon.species .. " HP matches the ROM")
eq(stats.attack, mon.stats.attack, mon.species .. " Attack matches")
eq(stats.defense, mon.stats.defense, mon.species .. " Defense matches")
eq(stats.speed, mon.stats.speed, mon.species .. " Speed matches")
eq(stats.specialAttack, mon.stats.specialAttack,
mon.species .. " Sp.Atk matches")
eq(stats.specialDefense, mon.stats.specialDefense,
mon.species .. " Sp.Def matches")
end
end
-- ================================================ roster lookup and gating
do
eq(BattleTower.roster(DATA), ROSTER, "the roster comes off trainers.lua")
eq(BattleTower.roster({ trainers = {} }), nil,
"a Gold cache has no battleTower block")
eq(BattleTower.roster({ trainers = { battleTower = { trainers = {} } } }), nil,
"and half a block is not one either")
local save = crystalSave(0)
eq(BattleTower.opponentGroup(save, ROSTER), 1,
"a level group of 0 cannot index before the table")
save.battleTower.levelGroup = 9
eq(BattleTower.opponentGroup(save, ROSTER), 2,
"nor past the last group the roster carries")
save.battleTower.levelGroup = 2
eq(BattleTower.opponentGroup(save, ROSTER), 2, "a real group is kept")
end
-- ============================================== the trainer draw and sBTTrainers
do
local save = crystalSave()
local trainer = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 }))
eq(trainer.name, "SAWYER", "the second row is drawn")
eq(save.battleTower.trainers[1], 1,
"and recorded in sBTTrainers[sNrOfBeatenBattleTowerTrainers]")
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:44-51: the
-- streak list is what the reroll refuses.
save.battleTower.streak = 1
local again = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 }))
check(again.name ~= "SAWYER", "a trainer already in the streak is refused")
eq(save.battleTower.trainers[2], again.index,
"and the new one lands in the next slot")
-- :29-37, Crystal 1.0's ceiling: with sampleTrainers 2 only the first two
-- rows can ever come out, however the roll lands.
local capped = { partyLength = 3, levelGroups = 1, uniqueMon = #GROUP1,
uniqueTrainers = 4, sampleTrainers = 2, trainers = ROSTER.trainers,
groups = { GROUP1 }, classSprites = ROSTER.classSprites }
local seen = {}
for roll = 1, 8 do
local fresh = crystalSave()
seen[BattleTower.chooseTrainer(fresh, capped, rolls({ roll })).index] = true
end
eq(seen[2], nil, "row 2 is past the 1.0 ceiling and never drawn")
eq(seen[3], nil, "nor row 3")
check(seen[0] and seen[1], "the first two rows both are")
end
-- ================================================== the three-mon team draw
do
local save = crystalSave()
local team = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 }))
eq(#team, 3, "three mons come out")
eq(team[1].species, "JOLTEON", "the first roll takes the first row")
-- :131-136, the species and item collisions: JOLTEON is out, and so is
-- KANGASKHAN, which holds JOLTEON's MIRACLEBERRY.
eq(team[2].species, "ESPEON",
"the second roll skips the species already picked")
eq(team[3].species, "UMBREON", "and so does the third")
local held = {}
for _, mon in ipairs(team) do
eq(held[mon.item], nil, mon.species .. " brings an unheld item")
held[mon.item] = true
end
-- :149-166 and :195-206: the last two teams' species are refused too.
eq(save.battleTower.prevTeams.prev[1], "JOLTEON",
"the drawn team becomes sBTMonPrevTrainer")
eq(#save.battleTower.prevTeams.prevPrev, 0,
"with nothing displaced into sBTMonPrevPrevTrainer yet")
local second = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 }))
eq(second[1].species, "WOBBUFFET", "the next team skips the previous one")
eq(second[2].species, "KANGASKHAN", "and keeps skipping it")
eq(save.battleTower.prevTeams.prevPrev[1], "JOLTEON",
"and the old team shifts down to sBTMonPrevPrevTrainer")
-- Five rows cannot field three fresh species twice running, which is
-- exactly where the cart's `jr z, .FindARandomBattleTowerMon` would spin
-- forever: the port takes the unfiltered draw instead and still hands back
-- a full team. Twenty-one rows never reach it.
eq(#second, 3, "an exhausted pool still fields three rather than hanging")
eq(second[3].species, "JOLTEON", "the third falls back to a plain roll")
end
-- ============================================== the party the battle fights
do
local party = BattleTower.battleParty(DATA, { GROUP1[1], GROUP1[3] })
eq(#party, 2, "one battle mon per roster row")
local jolteon = party[1]
eq(jolteon.species, "JOLTEON", "species")
eq(jolteon.name, "JOLTEON", "and the species name, not the ROM nickname")
eq(jolteon.nickname, nil, "which is why no nickname is carried")
eq(jolteon.level, 10, "level")
eq(jolteon.item, "MIRACLEBERRY", "held item")
eq(jolteon.happiness, 100, "happiness")
eq(jolteon.maxHp, 41, "max HP off the stored bytes")
eq(jolteon.hp, 41, "at full")
eq(jolteon.stats.speed, 37, "stored Speed")
eq(jolteon.stats.specialDefense, 31, "stored Sp.Def")
eq(jolteon.dvs.attack, 13, "the row's own DVs, not a roll")
eq(#jolteon.moves, 4, "four moves")
eq(jolteon.moves[2].id, "HYPER_BEAM", "move two")
eq(jolteon.moves[2].pp, 5, "with the row's PP")
eq(jolteon.moves[2].maxPp, 5, "and the move's own maximum")
eq(party[2].species, "UMBREON", "and the second row follows")
-- Mon.new mutates the dvs table it is handed, so a second build has to see
-- the roster row untouched.
local again = BattleTower.battleParty(DATA, { GROUP1[1] })
eq(again[1].stats.attack, 25, "a second build reads the same row")
eq(GROUP1[1].dvs.hp, nil, "and leaves the roster row alone")
end
-- ============================== LoadOpponentTrainerAndPokemonWithOTSprite
do
local painted = {}
local save = crystalSave()
local vm = fakeVm(save, {
setObjectSprite = function(object, sprite)
painted[#painted + 1] = { object = object, sprite = sprite }
end,
})
vm.scriptVar = 2
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
eq(vm.scriptVar, 2, "wScriptVar still names the object, unwritten")
check(vm.btOpponent ~= nil, "the opponent is drawn onto the VM")
eq(vm.btOpponent.name, "HANSON", "the trainer's own name")
eq(vm.btOpponent.classId, "FISHER", "its class")
eq(#vm.btOpponent.rows, 3, "with three mons")
eq(#painted, 1, "the map object is repainted once")
eq(painted[1].object, 2, "the object wScriptVar named")
eq(painted[1].sprite, "SPRITE_FISHER",
"with BTTrainerClassSprites[class - 1]")
eq(save.battleTower.streak, 0, "the draw does not step the streak")
-- A save is all it needs; a world with no sprite hook still draws.
local bare = crystalSave()
local plain = fakeVm(bare, {})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(plain)
check(plain.btOpponent ~= nil, "and the draw survives a missing hook")
-- A cache with no roster leaves nothing behind rather than half an opponent.
local goldVm = fakeVm(crystalSave(), { data = function() return {} end })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(goldVm)
eq(goldVm.btOpponent, nil, "a cache with no roster draws nobody")
end
-- ============================================================ BattleTowerBattle
local function towerBattle(save, outcome, extra)
local log = { heals = 0, started = nil }
local hooks = {
healParty = function() log.heals = log.heals + 1 end,
startTowerBattle = function(spec, done)
log.started = spec
log.healsAtStart = log.heals
done(outcome)
return true
end,
}
for key, value in pairs(extra or {}) do hooks[key] = value end
local vm = fakeVm(save, hooks)
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
Specials.ALL.BattleTowerBattle(vm)
return vm, log
end
do
local save = crystalSave()
local vm, log = towerBattle(save, "win")
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:236-237
eq(vm.scriptVar, 0, "a win leaves wBattleResult 0 in wScriptVar")
eq(log.heals, 2, ":228 and :235, HealParty on both sides of the battle")
eq(log.healsAtStart, 1, "the first one before the send-out")
check(log.started ~= nil, "the battle was started")
eq(log.started.name, "FISHER HANSON", "named class-then-trainer")
eq(log.started.trainerName, "HANSON", "with the bare name kept")
eq(log.started.classId, "FISHER", "and the class key the pic reads")
eq(log.started.class, 37, "and its constant")
eq(log.started.baseMoney, 40, "the class's own payout multiplier")
eq(#log.started.party, 3, "three mons walk in")
eq(log.started.party[1].species, "JOLTEON", "the drawn team, built")
eq(log.started.party[1].maxHp, 41, "with the roster's stats")
-- :549-570 CopyBTTrainer, which is what steps the counter.
eq(save.battleTower.streak, 1, "the streak steps once")
eq(save.battleTower.challenge, BattleTower.CHALLENGE_IN_PROGRESS,
"and the challenge is now in progress")
-- :240-250, the byte maps/BattleTowerBattleRoom.asm:38 reads back.
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 1,
"wNrOfBeatenBattleTowerTrainers holds the new count")
eq(vm.stringBuffer, "2", "and wStringBuffer3 the NEXT opponent's number")
eq(vm.btBattleEnded, 1, "wBattleTowerBattleEnded ends the loop")
eq(vm.btOpponent, nil, "and the drawn opponent is spent")
end
do
local save = crystalSave()
save.battleTower.streak = 6
local vm = towerBattle(save, "win")
eq(save.battleTower.streak, 7, "the seventh win reaches BATTLETOWER_STREAK_LENGTH")
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 7,
"which is what the room script's `ifequal 7` reads")
end
do
local save = crystalSave()
local vm, log = towerBattle(save, "lose")
eq(vm.scriptVar, 1, "a loss leaves wBattleResult 1")
eq(log.heals, 2, "and still heals on both sides")
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], nil,
":238-239 skips the counter copy when the battle was lost")
eq(vm.stringBuffer, "", "and prints no next-opponent number")
eq(save.battleTower.streak, 1,
"the counter still stepped, because ReadBTTrainerParty ran")
end
do
-- No hook at all: the challenge has to END rather than loop on an opponent
-- that never appears.
local save = crystalSave()
local vm = fakeVm(save, {})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.BattleTowerBattle(vm)
eq(vm.scriptVar, 1, "an unwired world reports a loss")
eq(vm.btBattleEnded, 1, "and ends the tower loop")
local goldVm = fakeVm(crystalSave(), { data = function() return {} end })
Specials.ALL.BattleTowerBattle(goldVm)
eq(goldVm.scriptVar, 1, "so does a cache with no roster")
end
do
-- The special draws its own opponent when the room script never loaded one.
local save = crystalSave()
local log
local vm = fakeVm(save, {
healParty = function() end,
startTowerBattle = function(spec, done) log = spec; done("win"); return true end,
})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.BattleTowerBattle(vm)
check(log ~= nil and #log.party == 3,
"BattleTowerBattle alone still fields an opponent")
end
-- ============================================ InitBattleTowerChallengeRAM
do
local save = crystalSave()
local vm = fakeVm(save, { pushScreen = function() return false end })
vm.mem[BattleTower.WRAM_NR_BEATEN] = 4
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 0,
":193 zeroes wNrOfBeatenBattleTowerTrainers with the rest")
eq(vm.btBattleEnded, 0, "and wBattleTowerBattleEnded with it")
end
Specials.random = math.random
S.finish()
+629
View File
@@ -0,0 +1,629 @@
-- The Battle Tower: engine/events/battle_tower/battle_tower.asm,
-- engine/events/battle_tower/rules.asm and the room menu at
-- mobile/mobile_46.asm:137.
--
-- luajit tests/gen2_battle_tower_test.lua
--
-- ROM-free. The lobby rules, the challenge-state machine, the level/uber
-- room gates, the reward roll and the wInBattleTowerBattle badge guard, all
-- against fixtures.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle tower")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Battle = require("src.battle.gen2.Battle")
local BattleTower = require("src.core.gen2.BattleTower")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
local Specials = require("src.script.gen2.Specials")
local A = BattleTower.ACTIONS
-- ---------------------------------------------------------------- fixtures
local ITEM_ORDER = {}
do
-- ../pokecrystal/constants/item_constants.asm: only the span the reward roll
-- walks has to be at its real index, so the filler carries the rest.
for i = 1, 250 do ITEM_ORDER[i] = "FILLER_" .. i end
ITEM_ORDER[18] = "POTION"
ITEM_ORDER[26] = "HP_UP"
ITEM_ORDER[27] = "PROTEIN"
ITEM_ORDER[28] = "IRON"
ITEM_ORDER[29] = "CARBOS"
ITEM_ORDER[30] = "LUCKY_PUNCH"
ITEM_ORDER[31] = "CALCIUM"
end
local DATA = {
gen2Constants = { itemOrder = ITEM_ORDER },
items = {
POTION = { index = 18, pocket = "ITEM" },
HP_UP = { index = 26, pocket = "ITEM" },
CALCIUM = { index = 31, pocket = "ITEM" },
},
}
local function mon(species, level, item, isEgg)
return { species = species, level = level, item = item, isEgg = isEgg }
end
local function crystalSave()
local save = { version = "crystal", player = { id = 7, badges = {} },
party = {}, inventory = {} }
return save
end
-- The hooks World:specialHooks hands a handler, stubbed.
local function fakeVm(opts)
opts = opts or {}
local vm = {
scriptVar = opts.scriptVar or 0,
specials = opts.hooks or {},
pages = {},
stringBuffer = "",
}
function vm:setStringBuffer(value) self.stringBuffer = value or "" end
function vm:showRaw(body)
body = tostring(body)
if self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer)
end
self.pages[#self.pages + 1] = body
end
return vm
end
local function towerVm(save, extra)
local hooks = {
save = function() return save end,
data = function() return DATA end,
party = function() return save.party end,
itemIndex = function(id)
for index, name in ipairs(ITEM_ORDER) do
if name == id then return index end
end
return 0
end,
}
for key, value in pairs(extra or {}) do hooks[key] = value end
return fakeVm({ hooks = hooks })
end
local function action(vm, id)
vm.scriptVar = id
Specials.ALL.BattleTowerAction(vm)
return vm.scriptVar
end
-- ============================================== the seam and the ownership
do
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-259 and
-- :1534-1576 joined the list once the roster reached trainers.lua; their own
-- behaviour is tests/gen2_battle_tower_battle_test.lua.
for _, name in ipairs({ "BattleTowerAction", "CheckForBattleTowerRules",
"Menu_ChallengeExplanationCancel", "BattleTowerRoomMenu",
"BattleTowerBattle", "LoadOpponentTrainerAndPokemonWithOTSprite" }) do
eq(Specials.HANDLER_SOURCE[name], "specials/battle_tower.lua",
name .. " is owned by specials/battle_tower.lua")
check(type(Specials.ALL[name]) == "function",
name .. " resolves to a function")
eq(Specials.STUBS[name], nil, name .. " is no longer a stub")
end
for _, name in ipairs({ "BattleTowerMobileError" }) do
check(type(Specials.STUB_REASONS[name]) == "string",
name .. " is still a deliberate stub, with its reason")
end
local found = false
for _, id in ipairs(Screens.GEN2_IDS) do
if id == "Gen2BattleTowerMenu" then found = true end
end
check(found, "Gen2BattleTowerMenu joined Screens.GEN2_IDS")
end
-- ================================================= _CheckForBattleTowerRules
do
local legal = { mon("PIKACHU", 40, "BERRY"), mon("GEODUDE", 40, "GOLD_BERRY"),
mon("HORSEA", 40) }
local lines, failed = BattleTower.checkRules(legal)
eq(#lines, 0, "a legal three-mon party prints nothing")
eq(failed, false, "and _CheckForBattleTowerRules returns no carry")
-- Two itemless mons do not collide: `ld a, [hl] / and a / jr z, .next`
-- drops a zero value before the inner scan ever runs.
local bare = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }
eq(#(BattleTower.checkRules(bare)), 0,
"three mons holding nothing is legal")
lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40) })
eq(table.concat(lines, "|"),
"_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|"
.. "_BattleTowerReturnWhenReadyText",
"two mons: the header, the count line and the tail")
lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("PIKACHU", 40),
mon("HORSEA", 40) })
eq(lines[2], "_TheMonMustAllBeDifferentKindsText",
"a duplicate species is the second check")
lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"),
mon("GEODUDE", 40, "BERRY"), mon("HORSEA", 40) })
eq(lines[2], "_TheMonMustNotHoldTheSameItemsText",
"a duplicate held item is the third")
lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40),
mon("ODD_EGG", 5, nil, true) })
eq(lines[2], "_YouCantTakeAnEggText", "an egg is the fourth")
-- An egg is skipped by BOTH uniqueness walks (CheckPartyValueIsUnique's
-- `.isegg` guards each side), so it only trips its own check.
lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"),
mon("PIKACHU", 40, "BERRY", true), mon("HORSEA", 40) })
eq(table.concat(lines, "|"),
"_ExcuseMeYoureNotReadyText|_YouCantTakeAnEggText|"
.. "_BattleTowerReturnWhenReadyText",
"an egg duplicating a species and an item still only fails the egg rule")
-- Every check runs; BattleTower_ExecuteJumptable does not stop at the first.
lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"),
mon("PIKACHU", 40, "BERRY"), mon("HORSEA", 40, "BERRY"),
mon("ODD_EGG", 5, nil, true) })
eq(table.concat(lines, "|"),
"_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|"
.. "_TheMonMustAllBeDifferentKindsText|"
.. "_TheMonMustNotHoldTheSameItemsText|_YouCantTakeAnEggText|"
.. "_BattleTowerReturnWhenReadyText",
"four failures print the header, all four lines and the tail, in order")
end
-- ---- the special itself
do
local save = crystalSave()
save.party = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }
local vm = towerVm(save)
Specials.ALL.CheckForBattleTowerRules(vm)
eq(vm.scriptVar, 0, "a legal party answers FALSE, the `ifnotequal FALSE` arm")
eq(#vm.pages, 0, "and prints nothing")
save.party = { mon("PIKACHU", 40) }
vm = towerVm(save)
Specials.ALL.CheckForBattleTowerRules(vm)
eq(vm.scriptVar, 1, "a broken party answers TRUE")
eq(#vm.pages, 3, "and prints header, refusal and tail")
eq(vm.stringBuffer, "3", "wStringBuffer2 holds the '3' the lines splice in")
end
-- ============================================== the rooms and their gates
do
local save = crystalSave()
eq(#BattleTower.levelGroupRows(save), 4,
"before the Hall of Fame only L10-L40 are offered")
save.hallOfFame = { count = 1, teams = {} }
eq(#BattleTower.levelGroupRows(save), 10,
"and all ten once STATUSFLAGS_HALL_OF_FAME_F is set")
-- maps/BattleTowerHallway.asm:40-47, the room the receptionist walks to.
eq(BattleTower.roomOf(1), 0, "L10 is the 10/20 room")
eq(BattleTower.roomOf(2), 0, "L20 shares it")
eq(BattleTower.roomOf(3), 1, "L30 is the 30/40 room")
eq(BattleTower.roomOf(4), 1, "L40 shares it")
eq(BattleTower.roomOf(9), 4, "L90 is the 90/100 room")
eq(BattleTower.roomOf(10), 4, "L100 shares it")
local party = { mon("PIKACHU", 30), mon("GEODUDE", 30), mon("HORSEA", 30) }
eq(BattleTower.levelCheck(party, 3), false, "L30 mons fit the L30 room")
eq(BattleTower.levelCheck(party, 2), true, "and top the L20 room")
party[1].level = 31
eq(BattleTower.levelCheck(party, 3), true, "one mon over is enough")
local ubers = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }
eq(BattleTower.ubersCheck(ubers, 4), "LUGIA",
"an uber is refused below the L70 rooms")
eq(BattleTower.ubersCheck(ubers, 7), nil,
"and allowed from the L70 room up")
ubers[1] = mon("MEWTWO", 70)
eq(BattleTower.ubersCheck(ubers, 6), nil,
"an uber already at L70 is past the `cp 70 / jr c` gate")
eq(BattleTower.ubersCheck({ mon("DRAGONITE", 40) }, 4), nil,
"and DRAGONITE is not on the list")
end
-- ==================================================== the reward (:906-976)
do
-- The `maskbits` roll is over eight values folded into six, so HP_UP and
-- PROTEIN come up twice as often; LUCKY_PUNCH is rerolled.
local seen = {}
for roll = 1, 8 do
local n = 0
local item = BattleTower.rollReward(ITEM_ORDER, function(mask)
n = n + 1
-- Specials.random answers 1..mask, and the handler folds it with % mask.
return (n == 1) and roll or 1
end)
seen[roll % 8] = item
end
eq(seen[1], "PROTEIN", "roll 1 is PROTEIN")
eq(seen[2], "IRON", "roll 2 is IRON")
eq(seen[3], "CARBOS", "roll 3 is CARBOS")
eq(seen[5], "CALCIUM", "roll 5 is CALCIUM")
eq(seen[0], "HP_UP", "roll 0 is HP_UP")
eq(seen[6], "HP_UP", "roll 6 folds back onto HP_UP")
eq(seen[7], "PROTEIN", "roll 7 folds back onto PROTEIN")
check(seen[4] ~= "LUCKY_PUNCH", "roll 4 lands on LUCKY_PUNCH and rerolls")
eq(BattleTower.rewardFits(19, 20, nil), true,
"a pocket with a free slot takes the five")
eq(BattleTower.rewardFits(20, 20, nil), false,
"a full pocket holding none of the reward cannot")
eq(BattleTower.rewardFits(20, 20, 94), true,
"a full pocket already holding 94 can stack five more")
eq(BattleTower.rewardFits(20, 20, 95), false,
"95 is where MAX_ITEM_STACK - 5 + 1 stops it")
end
-- ================================== BattleTowerAction, every jumptable row
do
local save = crystalSave()
local vm = towerVm(save)
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:855-887
local unhandled = {}
for id = 0, BattleTower.NUM_ACTIONS - 1 do
vm.scriptVar = id
local before = vm.scriptVar
local ok = pcall(Specials.ALL.BattleTowerAction, vm)
if not ok then unhandled[#unhandled + 1] = id end
if ok and vm.scriptVar == before and before ~= 0 then
-- a row that answers nothing must be one of the documented no-writes
local writesNothing = (id == A.SET_EXPLANATION_READ)
or (id == A.SAVE_AND_QUIT) or (id == A.CHALLENGECANCELED)
or (id == A.ACTION_06) or (id == A.SAVELEVELGROUP)
or (id == A.LOADLEVELGROUP) or (id == A.ACTION_0A)
or (id == A.ACTION_0C) or (id == A.ACTION_11) or (id == A.ACTION_12)
or (id == A.ACTION_15) or (id == A.ACTION_16) or (id == A.RESETDATA)
or (id == A.ACTION_1C) or (id == A.ACTION_1D)
or (id == A.CHOOSEREWARD) or (id == A.SAVEOPTIONS)
if not writesNothing then unhandled[#unhandled + 1] = id end
end
end
eq(table.concat(unhandled, ","), "",
"all 32 BattleTowerAction rows run, and only the cart's silent ones "
.. "leave wScriptVar alone")
end
-- ---- the challenge state machine (:992-1022, :935-949)
do
local save = crystalSave()
local vm = towerVm(save)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE,
"a fresh save is BATTLETOWER_NO_CHALLENGE")
action(vm, A.SAVE_AND_QUIT)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.SAVED_AND_LEFT,
"the quicksave arm parks on BATTLETOWER_SAVED_AND_LEFT")
action(vm, A.ACTION_1C)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.WON_CHALLENGE,
"beating the seventh trainer is BATTLETOWER_WON_CHALLENGE")
action(vm, A.ACTION_1D)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.RECEIVED_REWARD,
"and taking the prize is BATTLETOWER_RECEIVED_REWARD")
action(vm, A.CHALLENGECANCELED)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE,
"cancelling clears it")
end
-- ---- sBattleTowerSaveFileFlags (:978-1008, :1471-1492)
do
local save = crystalSave()
local tower = BattleTower.state(save)
eq(tower.saveFileFlags, 0, "a fresh save has no tower flags")
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION)
eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION), 2,
"`and 2` answers the masked byte, not a boolean")
eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_REGISTERED), 0,
"and bit 0 is untouched")
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION)
eq(tower.saveFileFlags, 2, "`or 2` twice is still 2")
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_REGISTERED)
eq(tower.saveFileFlags, 3, "both bits live in the one byte")
-- :979-982 -- with no save file on disk the routine returns FALSE without
-- ever reading the flags.
local vm = towerVm(save)
eq(action(vm, A.CHECK_EXPLANATION_READ), 0,
"no save file means the explanation check answers FALSE")
eq(action(vm, A.CHECKSAVEFILEISYOURS), 0,
"and so does the save-file check itself")
end
-- ---- the level group (:1129-1155) and the GS Ball (:1179-1185)
do
local save = crystalSave()
local vm = towerVm(save)
vm.btLevelGroup = 7
action(vm, A.SAVELEVELGROUP)
eq(BattleTower.state(save).levelGroup, 7,
"SAVELEVELGROUP banks wBTChoiceOfLvlGroup in SRAM")
vm.btLevelGroup = nil
action(vm, A.LOADLEVELGROUP)
eq(vm.btLevelGroup, 7, "and LOADLEVELGROUP brings it back for the hallway")
eq(action(vm, A.GSBALL), 0, "no GS Ball, no scene")
Save.crystalState(save).gsBall = "have"
eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE,
"sGSBallFlag reads back as GS_BALL_AVAILABLE ($b)")
Save.crystalState(save).gsBall = "given"
eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE,
"and stays set once the ball has been handed over")
end
-- ---- RESETDATA and the prize (:890-933, :955-976)
do
local save = crystalSave()
local tower = BattleTower.state(save)
tower.streak = 5
tower.trainers = { 3, 9 }
local vm = towerVm(save)
action(vm, A.RESETDATA)
eq(tower.streak, 0, "RESETDATA clears sNrOfBeatenBattleTowerTrainers")
eq(next(tower.trainers), nil, "and the seven sBTTrainers slots")
local rewards = {}
for _ = 1, 200 do
action(vm, A.CHOOSEREWARD)
rewards[tower.reward] = true
end
eq(rewards.POTION, nil,
"CHOOSEREWARD never falls back on POTION: it reads gen2Constants.itemOrder")
eq(rewards[BattleTower.SKIPPED_REWARD], nil, "and never rolls LUCKY_PUNCH")
for name in pairs(rewards) do
check(name == "HP_UP" or name == "PROTEIN" or name == "IRON"
or name == "CARBOS" or name == "CALCIUM",
name .. " is inside BATTLETOWER_MIN_REWARD..MAX_REWARD")
end
eq(rewards.HP_UP, true, "and the whole span comes up (HP_UP)")
eq(rewards.CALCIUM, true, "through CALCIUM")
tower.reward = "CALCIUM"
eq(action(vm, A.GIVEREWARD), 31, "GIVEREWARD answers the item id")
-- A full ITEM pocket that does not already hold the reward: the desk hands
-- over a POTION, which is the script's `ifequal POTION` arm.
for i = 1, 20 do save.inventory["FILLER_" .. i] = 1 end
eq(action(vm, A.GIVEREWARD), 18,
"a stuffed pack turns the prize into POTION")
end
-- ---- Menu_ChallengeExplanationCancel (mobile/mobile_5f.asm:425-468)
do
local save = crystalSave()
for row = 1, 3 do
local vm = towerVm(save, {
scriptMenu = function(_header, done) done(row) end,
})
vm.scriptVar = 1
Specials.ALL.Menu_ChallengeExplanationCancel(vm)
eq(vm.scriptVar, row, "row " .. row .. " comes back as wScriptVar")
end
local vm = towerVm(save, {
scriptMenu = function(_header, done) done(0) end,
})
vm.scriptVar = 1
Specials.ALL.Menu_ChallengeExplanationCancel(vm)
eq(vm.scriptVar, 4, "a B press is the `.Exit` arm's 4")
vm = towerVm(save)
vm.scriptVar = 1
Specials.ALL.Menu_ChallengeExplanationCancel(vm)
eq(vm.scriptVar, 4, "and so is a run with no menu to open")
end
-- ---- BattleTowerRoomMenu (battle_tower.asm:1-5)
do
local save = crystalSave()
save.hallOfFame = { count = 1, teams = {} }
local pushed
local vm = towerVm(save, {
pushScreen = function(id, opts)
pushed = id
opts.onDone(6)
return true
end,
})
Specials.ALL.BattleTowerRoomMenu(vm)
eq(pushed, "Gen2BattleTowerMenu", "the desk opens the room menu screen")
eq(vm.scriptVar, 0, "a chosen room answers 0, the script's `ifnotequal $0`")
eq(vm.btLevelGroup, 6, "and wBTChoiceOfLvlGroup holds the L60 room")
vm = towerVm(save, {
pushScreen = function(_id, opts)
opts.onDone(nil)
return true
end,
})
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.scriptVar, 0x0a, "cancelling answers $a, the desk's loop-back arm")
vm = towerVm(save)
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.scriptVar, 0x0a, "and so does a run with no screen to push")
end
-- ============================================ the room menu screen itself
do
eq(BattleTowerMenu.levelLabel(1), " L:10 ", "Strings_L10ToL100 row 1")
eq(BattleTowerMenu.levelLabel(10), " L:100", "and row 10, six tiles wide")
local pressed = {}
local game = { input = { wasPressed = function(_, name)
return pressed[name] == true
end } }
local function press(name) pressed = { [name] = true } end
local save = crystalSave()
save.hallOfFame = { count = 1, teams = {} }
local result, calls = nil, 0
local screen = BattleTowerMenu.new(game, {
save = save,
party = { mon("PIKACHU", 20), mon("GEODUDE", 20), mon("HORSEA", 20) },
onDone = function(value) result = value; calls = calls + 1 end,
})
eq(screen.cursor, 1, "the spinner opens on L:10")
eq(screen:rowCount(), 11, "ten rooms plus CANCEL")
press("up"); screen:update()
eq(screen.cursor, 2, "UP walks the level up")
press("down"); screen:update()
press("down"); screen:update()
eq(screen.cursor, 11, "DOWN off the top rolls over to CANCEL")
press("up"); screen:update()
eq(screen.cursor, 1, "and UP off CANCEL rolls back to L:10")
-- A party at L20 tops the L10 room (mobile/mobile_46.asm:3915-3917).
press("a"); screen:update()
eq(screen.phase, "message", "picking L:10 with L20 mons prints a refusal")
eq(calls, 0, "and does not answer the script")
for _ = 1, 0x80 do pressed = {}; screen:update() end
eq(screen.phase, "pick", "the $80-frame hold puts the menu back")
eq(screen.cursor, 1, "at jumptable index 0, cursor reset")
press("up"); screen:update()
press("a"); screen:update()
eq(result, 2, "L:20 is accepted and hands back the level group")
eq(calls, 1, "exactly once")
end
do
-- The uber gate, and the CANCEL path's yes/no.
local pressed = {}
local game = { input = { wasPressed = function(_, name)
return pressed[name] == true
end } }
local function press(name) pressed = { [name] = true } end
local save = crystalSave()
local result, answered = nil, false
local screen = BattleTowerMenu.new(game, {
save = save,
party = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) },
monName = function(id) return id end,
onDone = function(value) result = value; answered = true end,
})
eq(screen:rowCount(), 5, "no Hall of Fame, so four rooms plus CANCEL")
for _ = 1, 3 do press("up"); screen:update() end
eq(screen.cursor, 4, "on L:40")
press("a"); screen:update()
eq(screen.phase, "message", "an uber under L70 is refused below the L70 room")
check(screen.message:find("LUGIA", 1, true) ~= nil,
"and the refusal names it, the way text_ram wcd49 does")
for _ = 1, 0x80 do pressed = {}; screen:update() end
eq(screen.cursor, 1, "the refusal restarts the menu at L:10")
for _ = 1, 4 do press("up"); screen:update() end
eq(screen.cursor, 5, "CANCEL is the last row")
press("a"); screen:update()
eq(screen.phase, "quit", "CANCEL opens the yes/no")
eq(screen.yes, true, "on YES")
press("down"); screen:update()
press("a"); screen:update()
eq(screen.phase, "pick", "NO puts the level spinner back")
eq(answered, false, "and answers nothing yet")
press("b"); screen:update()
eq(screen.phase, "quit", "B on the spinner is the same cancel prompt")
press("a"); screen:update()
eq(answered, true, "YES ends the menu")
eq(result, nil, "with no level group, which the handler turns into $a")
end
-- ================================ wInBattleTowerBattle and the badge boosts
do
local GROWTH = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1,
squared = 0, linear = 0, constant = 0 } }
local BATTLE_DATA = {
pokemon = {
growthRates = GROWTH,
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} },
PIDGEY = { id = "PIDGEY", index = 16, name = "PIDGEY",
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} },
},
moves = {}, type_chart = { types = {}, matchups = {} }, items = {},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local BADGES = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
MINERAL = true, STORM = true, GLACIER = true, RISING = true }
local function newBattle(tower)
local player = Mon.new(BATTLE_DATA, "MACHOP", 40, { dvs = perfect })
local wild = Mon.new(BATTLE_DATA, "PIDGEY", 40, { dvs = perfect })
return Battle.new({ data = BATTLE_DATA, party = { player }, wild = wild,
save = { player = { id = 7, badges = BADGES, kantoBadges = {} } },
battleTower = tower,
random = function(n) return (n or 1) > 1 and 1 or 0 end }), player
end
local outside, player = newBattle(false)
eq(outside.inBattleTowerBattle, false, "an ordinary battle is not a Tower one")
eq(outside:battleStat(player, "attack"),
Battle.boostStat(player.stats.attack),
"outside the Tower ZEPHYRBADGE still boosts Attack")
eq(outside:battleStat(player, "speed"),
Battle.boostStat(player.stats.speed), "and PLAINBADGE Speed")
eq(outside:badgeTypeBoost(player, "FLYING"), true,
"and DoBadgeTypeBoosts still fires")
local inside, towerPlayer = newBattle(true)
eq(inside.inBattleTowerBattle, true, "the Tower battle sets it")
eq(inside:battleStat(towerPlayer, "attack"), towerPlayer.stats.attack,
"BadgeStatBoosts' second early return drops the Attack boost")
eq(inside:battleStat(towerPlayer, "defense"), towerPlayer.stats.defense,
"and Defense")
eq(inside:battleStat(towerPlayer, "speed"), towerPlayer.stats.speed,
"and Speed")
eq(inside:battleStat(towerPlayer, "specialAttack"),
towerPlayer.stats.specialAttack, "and Special Attack")
eq(inside:battleStat(towerPlayer, "specialDefense"),
towerPlayer.stats.specialDefense,
"and GLACIERBADGE's buggy Special Defense re-check with it")
eq(inside:badgeTypeBoost(towerPlayer, "FLYING"), false,
"DoBadgeTypeBoosts takes the same guard (engine/battle/misc.asm:152)")
-- The obedience ladder shares Battle:hasBadge and the cart does NOT guard
-- it (engine/battle/effect_commands.asm:671-696 reads wJohtoBadges raw).
eq(inside:obedienceLevel(), outside:obedienceLevel(),
"obedience reads the badges either way")
eq(inside:hasBadge("badges", "ZEPHYR"), true,
"and Battle:hasBadge itself is untouched")
end
-- ================================================ Gold and Silver are clean
do
for _, version in ipairs({ "gold", "silver" }) do
local save = Save.normalize({ version = version, generation = 2 })
eq(save.version, version, version .. " keeps its own version")
eq(save.battleTower, nil, version .. " grows no battleTower block")
eq(save.crystal, nil, "nor a crystal one")
end
local crystal = Save.normalize({ version = "crystal", generation = 2 })
eq(crystal.version, "crystal", "and the Crystal file stays Crystal")
check(type(crystal.battleTower) == "table",
"which does carry sBattleTowerChallengeState")
eq(crystal.battleTower.challenge, BattleTower.NO_CHALLENGE,
"starting at BATTLETOWER_NO_CHALLENGE")
end
S.finish()
+55
View File
@@ -0,0 +1,55 @@
-- TryToRunAwayFromBattle's battle-type ladder, which nothing covered:
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
-- FORCESHINY and SUICUNE, pokegold/engine/battle/core.asm:3476-3479 only the
-- first and third, and the values themselves come from
-- ../pokecrystal/constants/battle_constants.asm:91-103.
-- luajit tests/gen2_battletype_escape_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battletype escape")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Battle = require("src.battle.gen2.Battle")
-- ../pokecrystal/constants/battle_constants.asm:91-103, `const_def` from 0.
local NORMAL, CANLOSE, DEBUG, TUTORIAL = 0, 1, 2, 3
local FISH, ROAMING, CONTEST, FORCESHINY = 4, 5, 6, 7
local TREE, TRAP, FORCEITEM, CELEBI, SUICUNE = 8, 9, 10, 11, 12
eq(Battle.BATTLETYPE_CANLOSE, CANLOSE, "BATTLETYPE_CANLOSE is 1")
eq(Battle.BATTLETYPE_FORCESHINY, FORCESHINY, "BATTLETYPE_FORCESHINY is 7")
eq(Battle.BATTLETYPE_TRAP, TRAP, "BATTLETYPE_TRAP is 9")
eq(Battle.BATTLETYPE_CELEBI, CELEBI, "BATTLETYPE_CELEBI is 11, after FORCEITEM")
eq(Battle.BATTLETYPE_SUICUNE, SUICUNE, "BATTLETYPE_SUICUNE is 12")
local function refuses(value)
return Battle.noEscapeBattleType({ battleType = value }) == true
end
for _, row in ipairs({
{ NORMAL, false, "NORMAL" },
{ CANLOSE, false, "CANLOSE" },
{ DEBUG, false, "DEBUG" },
{ TUTORIAL, false, "TUTORIAL" },
{ FISH, false, "FISH" },
{ ROAMING, false, "ROAMING" },
{ CONTEST, false, "CONTEST" },
{ FORCESHINY, true, "FORCESHINY" },
{ TREE, false, "TREE" },
{ TRAP, true, "TRAP" },
-- ../pokecrystal/maps/TinTower1F.asm:120 and pokegold's Lugia and Ho-Oh both
-- arm FORCEITEM, which the ladder does not name: it is escapable.
{ FORCEITEM, false, "FORCEITEM" },
{ CELEBI, true, "CELEBI" },
{ SUICUNE, true, "SUICUNE" },
}) do
local value, want, name = row[1], row[2], row[3]
eq(refuses(value), want,
("%s (%d) %s escape"):format(name, value, want and "refuses" or "allows"))
end
check(refuses(nil) == false, "an unarmed battle escapes")
S.finish()
+71
View File
@@ -0,0 +1,71 @@
-- The two Ruins of Alph walls the FIELD MOVES open, at their production seams:
-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash
-- (ZEPHYRBADGE first, SpecialAerodactylChamber second, the darkness test last)
-- and :808-813 EscapeRopeOrDig's `.escaperope` arm.
-- luajit tests/gen2_chamber_fieldmoves_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 chamber field moves")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Events = require("src.world.gen2.Events")
local FieldMoves = require("src.world.gen2.FieldMoves")
local UnownWords = require("src.world.gen2.UnownWords")
local function ctxFor(mapId, badges, dark)
local events = Events.new()
return {
events = events,
save = { player = { badges = badges } },
dark = dark or false,
openAerodactylWall = function()
return UnownWords.aerodactylChamber(events, mapId)
end,
}, events
end
local AERO = UnownWords.CHAMBER_MAPS.AERODACTYL
-- :281-283, the badge gate ahead of the special.
local ctx, events = ctxFor(AERO, {}, false)
local refused = FieldMoves.fromMenu("FLASH", ctx)
eq(refused.ok, false, "no ZEPHYRBADGE: FLASH is refused in the chamber")
eq(refused.badge, "ZEPHYR", "and it is the badge that refused it")
check(not UnownWords.wallOpened(events, "AERODACTYL"),
"a badgeless press must not reach SpecialAerodactylChamber")
-- :284-287, the special's carry is a second way into `.useflash`.
ctx, events = ctxFor(AERO, { ZEPHYR = true }, false)
local used = FieldMoves.fromMenu("FLASH", ctx)
eq(used.ok, true, "with the badge FLASH runs in a chamber that is not dark")
eq(used.action, "flash", "and it queues the flash")
check(UnownWords.wallOpened(events, "AERODACTYL"),
"and the wall-opened flag is set")
-- :288-290, any other lit map still refuses.
ctx, events = ctxFor("RUINS_OF_ALPH_OUTSIDE", { ZEPHYR = true }, false)
eq(FieldMoves.fromMenu("FLASH", ctx).ok, false, "a lit route still refuses")
check(not UnownWords.wallOpened(events, "AERODACTYL"),
"and opens nothing")
-- A dark cave is the ordinary arm, with no chamber anywhere near it.
ctx = ctxFor("SLOWPOKE_WELL_B1F", { ZEPHYR = true }, true)
eq(FieldMoves.fromMenu("FLASH", ctx).ok, true, "a dark cave is still lit")
-- A cache with no chamber hook at all (Gold) must behave exactly as before.
eq(FieldMoves.fromMenu("FLASH", {
save = { player = { badges = { ZEPHYR = true } } }, dark = false }).ok, false,
"with no openAerodactylWall on the ctx, a lit map refuses")
-- ../pokecrystal/engine/events/unown_walls.asm:81, the escape rope's own arm.
local ropeEvents = Events.new()
eq(UnownWords.kabutoChamber(ropeEvents, "RUINS_OF_ALPH_OUTSIDE"), false,
"the rope opens nothing outside a chamber")
check(not UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag still clear")
eq(UnownWords.kabutoChamber(ropeEvents, UnownWords.CHAMBER_MAPS.KABUTO), true,
"and opens the Kabuto wall inside it")
check(UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag set")
S.finish()
+139
View File
@@ -0,0 +1,139 @@
-- Crystal's animated front pics: the bitmask decode and the frame sequencer.
-- luajit tests/gen2_crystal_anim_test.lua
--
-- ROM-free. The fixtures are the shapes the extractor writes into
-- data/generated/pokemon.lua, with the numbers taken from BULBASAUR's own
-- bitmask/frames data so a failure names the bytes it disagrees with.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal anim")
local check, eq = S.check, S.eq
local MonAnim = require("src.render.MonAnim")
-- ---- bitmask decode -------------------------------------------------------
-- ../pokecrystal/gfx/pokemon/bulbasaur/bitmask.asm bitmask 0 and frame 1:
-- %01100000 %10101101 %00000001 %00000000, then $19..$20. The tool emits the
-- byte low bit first, so the eight set bits are tile positions 5, 6, 8, 10,
-- 11, 13, 15 and 16 in the pic's own column-major order.
local BULBASAUR = {
tiles = 5,
bitmasks = {
{ 0x60, 0xad, 0x01, 0x00 },
{ 0x20, 0xad, 0x01, 0x00 },
},
frames = {
{ bitmask = 1, tiles = { 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20 } },
{ bitmask = 2, tiles = { 0x21, 0x1b, 0x22, 0x1d, 0x1e, 0x23, 0x24 } },
},
play = { { 1, 10 }, { 2, 10 }, { 0, 5 } },
idle = { { 2, 5 }, { 0, 5 } },
}
local base = MonAnim.tileMap(BULBASAUR, 0)
eq(base and #base, 25, "the base picture is 5x5 tiles")
eq(base and base[1], 0, "position 0 is tile 0")
eq(base and base[25], 24, "position 24 is tile 24")
local first = MonAnim.tileMap(BULBASAUR, 1)
eq(first and #first, 25, "frame 1 is still 5x5 tiles")
local REPLACED = { [6] = 0x19, [7] = 0x1a, [9] = 0x1b, [11] = 0x1c,
[12] = 0x1d, [14] = 0x1e, [16] = 0x1f, [17] = 0x20 }
local wrong = nil
for slot = 1, 25 do
local want = REPLACED[slot] or (slot - 1)
if first[slot] ~= want then wrong = wrong or slot end
end
eq(wrong, nil, "frame 1 replaces exactly the eight bitmask-0 positions")
-- Bitmask 1 drops bit 6 and keeps bit 5, so frame 2 leaves position 6 alone.
local second = MonAnim.tileMap(BULBASAUR, 2)
eq(second and second[6], 0x21, "frame 2 replaces position 5")
eq(second and second[7], 6, "and leaves position 6 at its base tile")
eq(second and #BULBASAUR.frames[2].tiles, 7,
"seven set bits, seven replacement tiles")
eq(MonAnim.tileMap({ frames = {}, bitmasks = {} }, 0), nil,
"no pic size means no tile map")
eq(MonAnim.tileMap(BULBASAUR, 9), nil, "and a frame that does not exist is nil")
-- ---- durations ------------------------------------------------------------
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16).
eq(MonAnim.duration(10, 0), 10, "speed 0 leaves a duration alone")
eq(MonAnim.duration(10, 4), 12, "ANIM_MON_SLOW's speed 4 stretches 10 to 12")
eq(MonAnim.duration(5, 4), 6, "and 5 to 6")
eq(MonAnim.duration(200, 4), 250, "the arithmetic stays inside a byte")
-- ---- the sequencer --------------------------------------------------------
-- setrepeat 2 / frame 1, 3 / frame 2, 2 / dorepeat 1 -- the shape most of
-- Crystal's entrance animations have (../pokecrystal/gfx/pokemon/abra/anim.asm).
local LOOPED = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { MonAnim.SETREPEAT, 2 }, { 1, 3 }, { 2, 2 }, { MonAnim.DOREPEAT, 1 } },
idle = { { 2, 2 } },
}
local function timeline(data, scene, ticks)
local anim = MonAnim.new(data, scene)
local out = {}
for _ = 1, ticks do
anim:update()
out[#out + 1] = anim:currentFrame()
end
return out, anim
end
local frames, anim = timeline(LOOPED, "battle", 14)
eq(table.concat(frames, ","), "0,1,1,1,2,2,1,1,1,2,2,2,0,0",
"the looped script plays twice and lands back on the base picture")
check(anim:finished(), "and the scene is over after PokeAnim_Finish")
-- Setup costs a frame of its own before the script starts, so the first
-- animation frame is on tick 2, not tick 1.
eq(frames[1], 0, "PokeAnim_Setup shows the base picture for its own frame")
-- ANIM_MON_SLOW's speed 4 stretches every duration: 8 becomes 10.
local ONESHOT = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { 1, 8 } }, idle = { { 2, 2 } },
}
eq(table.concat(timeline(ONESHOT, "battle", 11), ","),
"0,1,1,1,1,1,1,1,1,0,0", "speed 0 holds frame 1 for eight frames")
eq(table.concat(timeline(ONESHOT, "battleSlow", 13), ","),
"0,1,1,1,1,1,1,1,1,1,1,0,0", "speed 4 holds it for ten")
-- ANIM_MON_MENU: the entrance animation, an 18-frame pause, then the idle.
local menu = MonAnim.new(ONESHOT, "menu")
local seen = {}
for tick = 1, 31 do
menu:update()
seen[tick] = menu:currentFrame()
end
eq(seen[10], 0, "the entrance script ends on the base picture")
local resume
for tick = 11, 31 do
if seen[tick] ~= 0 then resume = tick break end
end
eq(resume, 11 + MonAnim.SCENE_WAIT + 1,
"eighteen wait frames, then PokeAnim_Idle's own frame, then the idle script")
eq(resume and seen[resume], 2, "and the idle script's first frame comes up")
check(not menu:finished(), "the menu scene is longer than the battle one")
local ran = 0
for tick = 1, 60 do
menu:update()
if menu:finished() then ran = tick break end
end
check(ran > 0, "but it does end")
-- ---- gating ---------------------------------------------------------------
eq(MonAnim.new(nil, "battle"), nil, "no data means no sequencer")
eq(MonAnim.new({ tiles = 5, play = {} }, "battle"), nil,
"and an empty script means no sequencer, which is every Gold species")
eq(MonAnim.new(LOOPED, "nosuchscene"), nil, "an unknown scene is nil too")
S.finish()
+314
View File
@@ -0,0 +1,314 @@
-- 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()
+559
View File
@@ -0,0 +1,559 @@
-- ../pokecrystal/engine/events/move_tutor.asm:1, engine/events/buena.asm:1
-- and :64, engine/events/poke_seer.asm:18, mobile/mobile_12_2.asm:191.
-- ROM-free:
-- luajit tests/gen2_crystal_extras_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
-- The same love stub tests/gen2_menus_test.lua installs; nothing here draws.
love = love or {}
love.graphics = love.graphics or {
getColor = function() return 1, 1, 1, 1 end,
setColor = function() end,
rectangle = function() end,
print = function() end,
printf = function() end,
draw = function() end,
newQuad = function() return {} end,
newImage = function() return nil end,
getShader = function() return nil end,
setShader = function() end,
newShader = function() error("no shaders in this harness") end,
getDimensions = function() return 160, 144 end,
push = function() end, pop = function() end,
translate = function() end, scale = function() end,
circle = function() end, clear = function() end,
}
love.math = love.math or { random = function(a, b) return b and a or 1 end }
love.image = love.image or {}
love.filesystem = love.filesystem or {
load = function() return nil end,
getInfo = function() return nil end,
read = function() return nil end,
}
love.timer = love.timer or { getTime = function() return 0 end }
local S = require("tests.harness").suite("gen2 crystal extras")
local check, eq = S.check, S.eq
require("src.core.Logger").warn = function() end
local Events = require("src.world.gen2.Events")
local Mon = require("src.battle.gen2.Mon")
local MoveTutorScreen = require("src.ui.gen2.MoveTutor")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
local Vm = require("src.script.gen2.Vm")
local H = Specials.HANDLERS
-- --------------------------------------------------------------- fixtures
-- The cache tables the handlers read, plus the landmark registry
-- GetLandmarkName walks (../pokecrystal/engine/overworld/landmarks.asm:16).
local DATA = {
pokemon = {
tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" },
TYPHLOSION = { index = 157, name = "TYPHLOSION",
tmhm = { "CUT", "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } },
GEODUDE = { index = 74, name = "GEODUDE",
tmhm = { "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } },
LAPRAS = { index = 131, name = "LAPRAS", tmhm = { "SURF" } },
CYNDAQUIL = { index = 155, name = "CYNDAQUIL" },
TOTODILE = { index = 158, name = "TOTODILE" },
CHIKORITA = { index = 152, name = "CHIKORITA" },
PIKACHU = { index = 25, name = "PIKACHU" },
RATTATA = { index = 19, name = "RATTATA" },
HOOTHOOT = { index = 163, name = "HOOTHOOT" },
SPINARAK = { index = 165, name = "SPINARAK" },
DROWZEE = { index = 96, name = "DROWZEE" },
},
items = {
ULTRA_BALL = { index = 2, name = "ULTRA BALL" },
FULL_RESTORE = { index = 16, name = "FULL RESTORE" },
NUGGET = { index = 92, name = "NUGGET" },
RARE_CANDY = { index = 50, name = "RARE CANDY" },
PROTEIN = { index = 33, name = "PROTEIN" },
IRON = { index = 34, name = "IRON" },
CARBOS = { index = 35, name = "CARBOS" },
CALCIUM = { index = 37, name = "CALCIUM" },
HP_UP = { index = 32, name = "HP UP" },
POTION = { index = 17, name = "POTION" },
ANTIDOTE = { index = 18, name = "ANTIDOTE" },
PARLYZ_HEAL = { index = 21, name = "PARLYZ HEAL" },
FRESH_WATER = { index = 39, name = "FRESH WATER" },
SODA_POP = { index = 40, name = "SODA POP" },
LEMONADE = { index = 41, name = "LEMONADE" },
POKE_BALL = { index = 5, name = "POKé BALL" },
GREAT_BALL = { index = 1, name = "GREAT BALL" },
X_ATTACK = { index = 68, name = "X ATTACK" },
X_DEFEND = { index = 69, name = "X DEFEND" },
X_SPEED = { index = 67, name = "X SPEED" },
},
moves = {
FLAMETHROWER = { name = "FLAMETHROWER" },
THUNDERBOLT = { name = "THUNDERBOLT" },
ICE_BEAM = { name = "ICE BEAM" },
TACKLE = { name = "TACKLE" },
GROWL = { name = "GROWL" },
MUD_SLAP = { name = "MUD-SLAP" },
},
gen2Landmarks = {
order = {},
landmarks = {
NEW_BARK_TOWN = { index = 1, name = "NEW BARK\nTOWN" },
ROUTE_29 = { index = 2, name = "ROUTE 29" },
},
},
}
local function newSave()
local save = {
version = "crystal",
player = { name = "KRIS", id = 0x1234, money = 0 },
party = {},
inventory = {},
bagOrder = {},
}
Save.crystalState(save)
return save
end
-- World:specialHooks, stubbed; every screen answers through its own onDone.
local function newHooks(save, opts)
opts = opts or {}
local vars = opts.vars or {}
local log = { pushed = {}, sfx = {} }
local hooks = {
log = log,
vars = vars,
save = function() return save end,
data = function() return DATA end,
party = function() return save.party end,
itemIndex = function(id)
local def = DATA.items[id]
return def and def.index
end,
hasItem = function(index) return opts.held and opts.held[index] == true end,
playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end,
selectPartyMon = function(prompt, done)
log.selectPrompt = prompt
done(opts.pickIndex, opts.pickIndex and save.party[opts.pickIndex])
end,
pushScreen = function(id, screenOpts)
log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts }
if opts.noScreens then return false end
local answer = opts.answers and opts.answers[#log.pushed]
if screenOpts.onDone then screenOpts.onDone(answer) end
return true
end,
}
return hooks
end
-- The var pair is Vm.readVarFn / Vm.writeVarFn, not a `specials` hook:
-- engine/overworld/variables.asm's rows belong to `readvar` / `writevar`.
local function newVm(hooks, scriptVar)
local vars = hooks.vars
local vm = Vm.new({}, {}, Events.new(), {
specials = hooks,
readVar = function(id) return vars[id] or 0 end,
writeVar = function(id, value) vars[id] = value end,
})
vm.showTextFn = function() end
vm.scriptVar = scriptVar or 0
return vm
end
-- Run one handler out, collecting its pages and feeding its YES/NO answers.
local function run(vm, handler, answers)
answers = answers or {}
local pages, asked = {}, 0
local co = coroutine.create(function() handler(vm) end)
vm.co = co
local send
while true do
local ok, req = coroutine.resume(co, send)
if not ok then error(req, 0) end
if coroutine.status(co) == "dead" then break end
send = nil
if type(req) == "table" and req.kind == "text" then
pages[#pages + 1] = req.text
elseif type(req) == "table" and req.kind == "yesorno" then
asked = asked + 1
send = answers[asked]
else
pages.parked = req
break
end
end
return pages
end
-- ============================================================ the move tutor
-- .GetMoveTutorMove (engine/events/move_tutor.asm:36-52); anything that is
-- not MOVETUTOR_FLAMETHROWER or _THUNDERBOLT falls through to MT03.
do
local function tutorMoveFor(value, answer)
local save = newSave()
local hooks = newHooks(save, { answers = { answer } })
local vm = newVm(hooks, value)
run(vm, H.MoveTutor)
local pushed = hooks.log.pushed[1]
return pushed and pushed.opts.move, vm.scriptVar
end
eq(select(1, tutorMoveFor(1, true)), "FLAMETHROWER", "MOVETUTOR_FLAMETHROWER is MT01")
eq(select(1, tutorMoveFor(2, true)), "THUNDERBOLT", "MOVETUTOR_THUNDERBOLT is MT02")
eq(select(1, tutorMoveFor(3, true)), "ICE_BEAM", "MOVETUTOR_ICE_BEAM is MT03")
eq(select(1, tutorMoveFor(0, true)), "ICE_BEAM",
"and anything else falls through to MT03, as the `cp` ladder does")
local _, learned = tutorMoveFor(1, true)
eq(learned, 0, "a taught move leaves wScriptVar FALSE, which is .TeachMove")
local _, cancelled = tutorMoveFor(1, false)
eq(cancelled, 255, "and a cancel leaves -1, which is .Incompatible")
end
-- engine/events/move_tutor.asm:29 .cancel, ahead of the script's
-- takecoins 4000 (maps/GoldenrodCity.asm:124).
do
local save = newSave()
local hooks = newHooks(save, { noScreens = true })
local vm = newVm(hooks, 2)
run(vm, H.MoveTutor)
eq(vm.scriptVar, 255, "no screen is the -1 cancel, not a free lesson")
end
-- `add_mt` gives the three tutor moves TMHM flags 58-60
-- (constants/item_constants.asm:295-307), so CanLearnTMHMMove sees them.
do
local view = MoveTutorScreen.speciesView(DATA.pokemon)
check(MoveTutorScreen.canLearn(view.TYPHLOSION, "FLAMETHROWER"),
"TYPHLOSION can be taught FLAMETHROWER")
check(MoveTutorScreen.canLearn(view.TYPHLOSION, "CUT"),
"and its ordinary TM list still answers")
check(not MoveTutorScreen.canLearn(view.LAPRAS, "FLAMETHROWER"),
"LAPRAS cannot")
local merged = view.TYPHLOSION.tmhm
eq(#merged, 3, "the view's tmhm list carries the tutor row too")
eq(merged[3], "FLAMETHROWER", "appended after the TM/HM rows")
check(view.TYPHLOSION == view.TYPHLOSION, "and the view is memoised")
eq(view.MISSINGNO, nil, "a species the cache does not carry stays nil")
eq(#DATA.pokemon.TYPHLOSION.tmhm, 2, "the cache's own record is not touched")
end
-- ================================================================== Buena
-- engine/events/buena_menu.asm:1-9: carry (NO or B) is 0, YES is 1.
do
local save = newSave()
local vm = newVm(newHooks(save))
run(vm, H.AskRememberPassword, { true })
eq(vm.scriptVar, 1, "YES is 1, which the script reads as `iffalse` not taken")
local vm2 = newVm(newHooks(save))
run(vm2, H.AskRememberPassword, { false })
eq(vm2.scriptVar, 0, "and NO is 0, .ForgotPassword")
end
-- engine/events/buena.asm:19-23: only wBuenasPassword's low nybble is right.
do
local save = newSave()
-- BuenasPassword4's two rejection rolls, pinned
-- (engine/pokegear/radio.asm:1470-1487).
local rolls = { 1, 2 }
local taken = 0
local realRandom = Specials.random
Specials.random = function() taken = taken + 1 return rolls[taken] end
local hooks = newHooks(save, { answers = { 1 } })
local vm = newVm(hooks)
run(vm, H.BuenasPassword)
Specials.random = realRandom
local pushed = hooks.log.pushed[1]
eq(pushed.id, "Gen2BuenaPassword", "the show opens its own menu")
eq(pushed.opts.mode, "password", "in password mode")
eq(pushed.opts.width, 10, "the box is as wide as the category's points byte")
eq(table.concat(pushed.opts.words, ","), "CYNDAQUIL,TOTODILE,CHIKORITA",
".PlacePasswordChoices resolves BUENA_MON rows through GetPokemonName")
eq(vm.scriptVar, 1, "picking the low nybble's row is the right answer")
eq(save.crystal.buenaPassword.word, 0x01,
"and the roll is packed group-high, word-low into wBuenasPassword")
eq(hooks.vars[0x19], 0x01, "VAR_BUENASPASSWORD sees the same byte")
-- DAILYFLAGS2_BUENAS_PASSWORD_F holds the roll for the day
-- (engine/pokegear/radio.asm:1467).
local hooks2 = newHooks(save, { answers = { 0 } })
local vm2 = newVm(hooks2)
run(vm2, H.BuenasPassword)
eq(vm2.scriptVar, 0, "row 0 is a wrong guess")
eq(save.crystal.buenaPassword.word, 0x01, "and the day's password is unchanged")
eq(table.concat(hooks2.log.pushed[1].opts.words, ","),
"CYNDAQUIL,TOTODILE,CHIKORITA", "so the menu offers the same three words")
end
-- GetBuenasPassword's four BUENA_* arms (engine/pokegear/radio.asm:1534),
-- and the points byte as the menu width (engine/events/buena.asm:9-12).
do
local save = newSave()
local realRandom = Specials.random
local function pin(group, word)
local rolls, taken = { group + 1, word + 1 }, 0
Specials.random = function() taken = taken + 1 return rolls[taken] end
save.crystal.buenaPassword.word = nil
save.crystal.buenaPassword.day = nil
local hooks = newHooks(save, { answers = { word } })
local vm = newVm(hooks)
run(vm, H.BuenasPassword)
return hooks.log.pushed[1].opts, vm.scriptVar
end
local balls = pin(3, 2)
eq(table.concat(balls.words, ","), "POKé BALL,GREAT BALL,ULTRA BALL",
"BUENA_ITEM rows resolve through GetItemName")
eq(balls.width, 12, "and .Balls is worth 12 points")
local towns = pin(6, 0)
eq(table.concat(towns.words, ","), "NEW BARK TOWN,CHERRYGROVE CITY,AZALEA TOWN",
"BUENA_STRING rows are the literals themselves")
eq(towns.width, 16, "the widest category is 16 wide")
local moves = pin(8, 1)
eq(table.concat(moves.words, ","), "TACKLE,GROWL,MUD-SLAP",
"BUENA_MOVE rows resolve through GetMoveName")
local types = pin(7, 0)
eq(types.width, 6, ".Types is the narrowest box at 6")
local _, right = pin(10, 2)
eq(right, 1, "the eleventh category exists and its third row can be picked")
Specials.random = realRandom
end
-- engine/events/buena.asm:25 .wrong is what a menu-less run has to take.
do
local save = newSave()
local vm = newVm(newHooks(save, { noScreens = true }))
run(vm, H.BuenasPassword)
eq(vm.scriptVar, 0, "no menu means no correct answer")
end
-- ------------------------------------------------------------ the prizes
-- data/items/buena_prizes.asm, and the cost / ReceiveItem / subtract order
-- (engine/events/buena.asm:95-119).
do
local save = newSave()
local hooks = newHooks(save, { vars = { [0x18] = 5 }, answers = { 1 } })
local vm = newVm(hooks)
local pages = run(vm, H.BuenaPrize, { true })
local pushed = hooks.log.pushed[1]
eq(pushed.opts.mode, "prize", "the counter opens the prize list")
eq(#pushed.opts.prizes, 9, "NUM_BUENA_PRIZES rows")
eq(pushed.opts.prizes[1].name, "ULTRA BALL", "the first prize")
eq(pushed.opts.prizes[1].cost, 2, "costs 2 points")
eq(pushed.opts.prizes[9].name, "HP UP", "the last prize")
eq(pushed.opts.prizes[9].cost, 5, "costs 5")
eq(pushed.opts.balance, 5, "and the Points box shows wBlueCardBalance")
eq(save.inventory.ULTRA_BALL, 1, "one ULTRA BALL leaves the counter")
eq(hooks.vars[0x18], 3, "and the two points come off the card")
eq(hooks.log.sfx[1], "Sfx_Transaction", "SFX_TRANSACTION rings the sale")
eq(pages[#pages], "Oh. Please come\nback again!",
".done prints _BuenaComeAgainText after the loop")
end
-- engine/events/buena.asm:93 `jr c, .loop`: nothing is spent.
do
local save = newSave()
local hooks = newHooks(save, { vars = { [0x18] = 9 }, answers = { 1, 0 } })
local vm = newVm(hooks)
run(vm, H.BuenaPrize, { false })
eq(save.inventory.ULTRA_BALL, nil, "a refused confirmation buys nothing")
eq(hooks.vars[0x18], 9, "and spends no points")
eq(#hooks.log.pushed, 2, "the list reopens once before the B press ends it")
end
-- .InsufficientBalance and .BagFull (engine/events/buena.asm:121-127).
do
local save = newSave()
local hooks = newHooks(save, { vars = { [0x18] = 1 }, answers = { 1, 0 } })
local vm = newVm(hooks)
local pages = run(vm, H.BuenaPrize, { true })
eq(save.inventory.ULTRA_BALL, nil, "one point cannot buy a two point ball")
eq(hooks.vars[0x18], 1, "the balance is untouched")
check(pages[3] == "You don't have\nenough points.",
"_BuenaNotEnoughPointsText is the refusal")
-- ReceiveItem's own refusal: a stack already at 99.
local full = newSave()
full.inventory.ULTRA_BALL = 99
local fullHooks = newHooks(full, { vars = { [0x18] = 9 }, answers = { 1, 0 } })
local fullVm = newVm(fullHooks)
local fullPages = run(fullVm, H.BuenaPrize, { true })
eq(full.inventory.ULTRA_BALL, 99, "a full stack takes no more")
eq(fullHooks.vars[0x18], 9, "a full bag spends nothing either")
check(fullPages[3] == "You have no room\nfor it.",
"_BuenaNoRoomText is that refusal")
end
-- ================================================================ the Seer
local function seerMon(fields)
local mon = {
species = "GEODUDE", nickname = "ROCKY", level = 30,
otId = 0x1234, otName = "KRIS", moves = {},
}
for key, value in pairs(fields or {}) do mon[key] = value end
return mon
end
local function runSeer(mon, cancel)
local save = newSave()
save.party = { mon }
local hooks = newHooks(save, { pickIndex = (not cancel) and 1 or nil })
local vm = newVm(hooks)
return run(vm, H.PokeSeer), hooks
end
-- engine/events/poke_seer.asm:38 .cancel, SeerDoNothingText.
do
local pages = runSeer(seerMon(), true)
eq(#pages, 2, "the intro and then the refusal")
eq(pages[2], "Fufufu! I saw that\nyou'd do nothing!", "_SeerDoNothingText")
end
-- engine/events/poke_seer.asm:28-29 `cp EGG / jr z, .egg`.
do
local pages = runSeer(seerMon({ isEgg = true }))
eq(pages[2], "Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…",
"_SeerEggText")
end
-- ReadCaughtData's `.error` (engine/events/poke_seer.asm:104-105, :133).
do
local pages = runSeer(seerMon())
check(pages[2]:find("Whaaaat", 1, true) ~= nil,
"a mon with no caught data at all gets _SeerCantTellAThingText")
end
-- SeerAction0 (engine/events/poke_seer.asm:64-70), then SeerAdvice.
do
local mon = seerMon({ caughtTime = 1, caughtLevel = 10,
caughtLocation = 1, caughtByGender = "boy" })
local pages = runSeer(mon)
eq(pages[2], "Hm… I see you met\nROCKY here:\vNEW BARK TOWN!",
"_SeerNameLocationText, with the town map's line break spent as a space")
eq(pages[3], "The time was\nMorning!\fIts level was 10!\fAm I good or what?",
"_SeerTimeLevelText")
check(pages[4]:find("more confident", 1, true) ~= nil,
"a 20 level gain is SeerMoreConfidentText, the 29 row")
end
-- SeerAction1, and engine/events/poke_seer.asm:110-119 where the OT id's
-- second `cp` is commented out, so only the HIGH byte decides.
do
local traded = seerMon({ otId = 0x9999, otName = "SILVER",
caughtTime = 3, caughtLevel = 5, caughtLocation = 2 })
local pages = runSeer(traded)
eq(pages[2], "Hm… ROCKY\ncame from SILVER\vin a trade?\fROUTE 29\n"
.. "was where SILVER\vmet ROCKY!", "_SeerTradeText")
eq(pages[3], "The time was\nNight!\fIts level was 5!\fAm I good or what?",
"the same time/level page follows")
local sameHigh = seerMon({ otId = 0x12ff, caughtTime = 2, caughtLevel = 5,
caughtLocation = 2 })
local samePages = runSeer(sameHigh)
check(samePages[2]:find("I see you met", 1, true) ~= nil,
"an OT id that differs only in its LOW byte reads as met, bug and all")
end
-- GetCaughtLocation's two sentinels (engine/events/poke_seer.asm:239-249).
do
local event = seerMon({ caughtLevel = 20, caughtLocation = Mon.LANDMARK_EVENT })
local pages = runSeer(event)
check(pages[2]:find("What!? Incredible!", 1, true) ~= nil,
"LANDMARK_EVENT is SEERACTION_LEVEL_ONLY, _SeerNoLocationText")
check(pages[2]:find("was at level 20", 1, true) ~= nil,
"which still prints the level")
check(pages[3] ~= nil, "and still gives the advice page")
local gift = seerMon({ caughtLevel = 5, caughtLocation = Mon.LANDMARK_GIFT })
local giftPages = runSeer(gift)
check(giftPages[2]:find("Whaaaat", 1, true) ~= nil,
"LANDMARK_GIFT is SEERACTION_CANT_TELL_2, the same refusal")
eq(#giftPages, 2, "and there is no advice behind it")
end
-- GetCaughtLevel (engine/events/poke_seer.asm:148-179) and GetCaughtTime's
-- .none arm (:198-201).
do
local unknown = seerMon({ caughtLevel = 0, caughtLocation = 1, caughtTime = 0 })
-- byte1 is set, so ReadCaughtData's `or [hl]` misses its `.error` arm
-- (engine/events/poke_seer.asm:105).
local pages = runSeer(unknown)
eq(pages[3], "The time was\nUnknown!\fIts level was ???!\fAm I good or what?",
"no time and no level print Unknown and ???")
local hatched = seerMon({ caughtLevel = 1, caughtLocation = 1, caughtTime = 2,
level = 6 })
local hatchedPages = runSeer(hatched)
check(hatchedPages[3]:find("level was 5", 1, true) ~= nil,
"CAUGHT_EGG_LEVEL prints EGG_LEVEL, not 1")
end
-- SeerAdviceTexts (engine/events/poke_seer.asm:357-364), walked in order;
-- `sub c` is one byte, so the 255 row catches an underflow.
do
local function adviceFor(level, caught)
local mon = seerMon({ level = level, caughtLevel = caught,
caughtLocation = 1, caughtTime = 2 })
return runSeer(mon)[4]
end
check(adviceFor(12, 10):find("little more care", 1, true) ~= nil,
"a 2 level gain is the 9 row")
check(adviceFor(35, 10):find("more confident", 1, true) ~= nil,
"25 is the 29 row")
check(adviceFor(50, 10):find("much strength", 1, true) ~= nil,
"40 is the 59 row")
check(adviceFor(70, 10):find("grown mighty", 1, true) ~= nil,
"60 is the 89 row")
check(adviceFor(95, 5):find("I'm impressed", 1, true) ~= nil,
"90 is the 100 row")
check(adviceFor(5, 10):find("little more care", 1, true) ~= nil,
"and `sub c` underflowing lands on the 255 row, which repeats the first")
end
-- ================================================ UnusedFindItemInPCOrBag
-- mobile/mobile_12_2.asm:194 wNumPCItems, then :201 wNumItems.
do
local save = newSave()
save.pcItems = { RARE_CANDY = 1 }
local vm = newVm(newHooks(save), 50)
run(vm, H.UnusedFindItemInPCOrBag)
eq(vm.scriptVar, 1, "an item in the PC answers TRUE")
local vm2 = newVm(newHooks(save, { held = { [92] = true } }), 92)
run(vm2, H.UnusedFindItemInPCOrBag)
eq(vm2.scriptVar, 1, "an item in the pack answers TRUE too")
local vm3 = newVm(newHooks(save), 92)
run(vm3, H.UnusedFindItemInPCOrBag)
eq(vm3.scriptVar, 0, "and neither is FALSE")
end
-- ==================================================== the seam these ride
do
for _, name in ipairs({ "MoveTutor", "BuenasPassword", "BuenaPrize",
"AskRememberPassword", "PokeSeer", "UnusedFindItemInPCOrBag" }) do
eq(Specials.HANDLER_SOURCE[name], "specials/crystal_extras.lua",
name .. " is owned by this module")
eq(Specials.STUBS[name], nil, name .. " is no longer a stub")
check(Specials.SUPERSEDED_STUBS[name] ~= nil,
"and its old stub reason was retired")
end
local ids = {}
for _, id in ipairs(require("src.ui.Screens").GEN2_IDS) do ids[id] = true end
check(ids.Gen2MoveTutor, "Gen2MoveTutor is a registered screen id")
check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too")
end
S.finish()
+144
View File
@@ -0,0 +1,144 @@
-- ENGINE_PLAYER_IS_FEMALE, the Crystal engine flag `checkflag` asks at 14 map
-- sites, and the Crystal-only Surf refusal that shares the wiring.
-- luajit tests/gen2_crystal_gender_flag_test.lua
--
-- Both come in a Crystal half and a Gold half: Gold declares no such flag and
-- keeps its documented "you can Surf on top of NPCs" bug (CT-10).
-- 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 gender flag")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local Save = require("src.core.gen2.Save")
local World = require("src.world.gen2.World")
local priorVersion = GameVersion.get()
-- ------------------------------------------------- the flag id, by name
-- constants/engine_flags.asm's const block, with the badge rows at the ids a
-- real crystal cache reports and a hole where parse_const_block met a
-- const_skip. ENGINE_PLAYER_IS_FEMALE is row 100, so flag id 99.
local CRYSTAL_ORDER = {}
CRYSTAL_ORDER[27] = "ENGINE_ZEPHYRBADGE"
CRYSTAL_ORDER[28] = "ENGINE_HIVEBADGE"
CRYSTAL_ORDER[29] = "ENGINE_PLAINBADGE"
CRYSTAL_ORDER[30] = "ENGINE_FOGBADGE"
CRYSTAL_ORDER[31] = "ENGINE_MINERALBADGE"
CRYSTAL_ORDER[32] = "ENGINE_STORMBADGE"
CRYSTAL_ORDER[33] = "ENGINE_GLACIERBADGE"
CRYSTAL_ORDER[34] = "ENGINE_RISINGBADGE"
CRYSTAL_ORDER[35] = "ENGINE_BOULDERBADGE"
CRYSTAL_ORDER[42] = "ENGINE_EARTHBADGE"
-- the hole: nothing at 43..99, which is where ipairs would stop
CRYSTAL_ORDER[100] = "ENGINE_PLAYER_IS_FEMALE"
FieldMoves.bindEngineFlags(CRYSTAL_ORDER)
eq(FieldMoves.FEMALE_FLAG, 99, "ENGINE_PLAYER_IS_FEMALE is flag 99 by NAME")
eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR",
"and the badge block walks past the const_skip hole")
eq(FieldMoves.BADGE_FLAG[41].name, "EARTH", "including the last Kanto row")
FieldMoves.bindEngineFlags(nil)
eq(FieldMoves.FEMALE_FLAG, nil, "Gold declares no such flag at all")
eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "and keeps its own badge ids")
-- ------------------------------------------------- the flag, through World
local function flagWorld(version, gender, order)
local game = {
data = {},
save = {
version = version,
player = { name = "CHRIS", id = 1, gender = gender, badges = {} },
engineFlags = {},
},
}
FieldMoves.bindEngineFlags(order)
local world = World.new(game)
world.constants = {}
return world, game
end
do
local world, game = flagWorld("crystal", "female", CRYSTAL_ORDER)
check(world:engineFlag(99), "Kris answers checkflag ENGINE_PLAYER_IS_FEMALE")
check(not (game.save.engineFlags or {})[99],
"and it is NOT a second copy in save.engineFlags")
game.save.player.gender = "male"
check(not world:engineFlag(99), "Chris answers the same flag false")
-- the write half exists only so a stray setflag cannot open a second store
world:setEngineFlag(99, true)
eq(game.save.player.gender, "female", "setflag writes the gender byte")
check(not (game.save.engineFlags or {})[99], "still one store")
world:setEngineFlag(99, false)
eq(game.save.player.gender, "male", "and clearflag writes it back")
end
do
local world, game = flagWorld("gold", "male", nil)
check(not world:engineFlag(99), "Gold's flag 99 is an ordinary flag")
world:setEngineFlag(99, true)
check(world:engineFlag(99), "which reads back out of save.engineFlags")
check((game.save.engineFlags or {})[99] == true, "where Gold keeps it")
eq(game.save.player.gender, "male", "and the gender byte is untouched")
end
-- ------------------------------------------------- Save.isFemale, the seam
check(Save.isFemale({ player = { gender = "female" } }), "isFemale reads Kris")
check(not Save.isFemale({ player = { gender = "male" } }), "and not Chris")
check(not Save.isFemale({ player = {} }), "a genderless save is Chris")
check(not Save.isFemale(nil), "and so is no save at all")
-- ------------------------------------------------- Surf onto an NPC (CT-10)
local WATER = 0x29 -- pokecrystal constants/collision_constants.asm
local function surfCtx(facingObject)
return {
save = { player = { badges = { FOG = true } } },
mon = { species = "LAPRAS" },
facing = "down",
facingColl = WATER,
playerColl = 0x00,
playerState = FieldMoves.PLAYER_NORMAL,
facingObject = facingObject,
}
end
GameVersion.set("crystal")
check(FieldMoves.surfFromMenu(surfCtx(nil)).ok,
"Crystal surfs onto open water")
check(not FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok,
"and refuses onto a facing object (CheckFacingObject)")
eq(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).text,
FieldMoves.TEXT.CANT_SURF, "with .cannotsurf's own line")
GameVersion.set("gold")
check(FieldMoves.surfFromMenu(surfCtx(nil)).ok, "Gold surfs onto open water")
check(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok,
"and KEEPS the cart's bug: Surf right on top of the NPC")
GameVersion.set("silver")
check(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok,
"Silver keeps it too")
-- TrySurfOW is byte-identical in both trees, so the OW path never gained the
-- check (pokecrystal engine/events/overworld.asm:484-514 vs pokegold :469-499).
GameVersion.set("crystal")
do
local ctx = surfCtx({ id = "npc" })
ctx.party = { { species = "LAPRAS", moves = { { id = "SURF" } } } }
check(FieldMoves.trySurfOW(ctx).ok,
"the A-press path is unchanged by the menu fix, even on Crystal")
end
GameVersion.set(priorVersion)
FieldMoves.bindEngineFlags(nil)
S.finish()
+334
View File
@@ -0,0 +1,334 @@
-- Crystal's player gender: the InitGender screen, the byte it writes, and the
-- eight places that read it back.
-- luajit tests/gen2_crystal_gender_test.lua
--
-- Gold and Silver have no Kris to extract, so every assertion comes in a
-- Crystal half and a Gold half: Gold must still boot straight into Oak with no
-- gender beat in the speech and Chris on the field.
-- 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 gender")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local Pokegear = require("src.ui.gen2.Pokegear")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
local TrainerCard = require("src.ui.gen2.TrainerCard")
local priorVersion = GameVersion.get()
-- ------------------------------------------------- the two sprite tables
-- data/sprites/player_sprites.asm
eq(FieldMoves.stateSprite("normal", "male"), "SPRITE_CHRIS", "Chris on foot")
eq(FieldMoves.stateSprite("bike", "male"), "SPRITE_CHRIS_BIKE", "Chris biking")
eq(FieldMoves.stateSprite("normal", "female"), "SPRITE_KRIS", "Kris on foot")
eq(FieldMoves.stateSprite("bike", "female"), "SPRITE_KRIS_BIKE", "Kris biking")
eq(FieldMoves.stateSprite("surf", "female"), "SPRITE_SURF",
"both share the Lapras")
eq(FieldMoves.stateSprite("surf_pika", "female"), "SPRITE_SURFING_PIKACHU",
"and the surfing PIKACHU")
eq(FieldMoves.stateSprite("normal", nil), "SPRITE_CHRIS",
"no recorded gender is PLAYERGENDER_MALE")
eq(FieldMoves.stateSprite("skate", "female"), "SPRITE_KRIS",
"a state with no row falls back to PLAYER_NORMAL's")
eq(FieldMoves.playerSprite("female"), "SPRITE_KRIS", "playerSprite is the same")
check(FieldMoves.isFemale("female"), "female reads as PLAYERGENDER_FEMALE_F")
check(not FieldMoves.isFemale("male"), "male does not")
check(not FieldMoves.isFemale(nil), "and neither does an absent byte")
check(FieldMoves.hasGenderChoice({ SPRITE_KRIS = {} }),
"a cache with SPRITE_KRIS offers the choice")
check(not FieldMoves.hasGenderChoice({ SPRITE_CHRIS = {} }),
"a Gold cache does not")
check(not FieldMoves.hasGenderChoice(nil), "and neither does no cache at all")
-- ------------------------------------------------- the save field (CT-5)
eq(Save.defaultPlayerName("crystal", "male"), "CHRIS", "MalePlayerNameArray[0]")
eq(Save.defaultPlayerName("crystal", "female"), "KRIS",
"FemalePlayerNameArray[0]")
eq(Save.defaultPlayerName("gold", "female"), "GOLD",
"Gold has no female array to fall into")
eq(Save.defaultPlayerName("silver", "female"), "SILVER", "nor does Silver")
eq(Save.defaultPlayerName("crystal"), "CHRIS", "and no gender is Chris")
check(Save.isFemale({ player = { gender = "female" } }), "isFemale on a save")
check(not Save.isFemale({ player = { gender = "male" } }), "and not on Chris")
check(not Save.isFemale({}), "nor on a save with no player block")
GameVersion.set("crystal")
local kris = Save.newGame({ gender = "female" })
eq(kris.player.gender, "female", "newGame records the answer")
eq(kris.player.name, "KRIS", "and lays down .Kris as the default name")
local chris = Save.newGame({})
eq(chris.player.gender, "male", "InitCrystalData zeroes the byte")
eq(chris.player.name, "CHRIS", "so a fresh save is Chris")
eq(Save.newGame({ gender = "female", playerName = "ZZ" }).player.name, "ZZ",
"an explicit name still wins")
local normalized = Save.normalize({ version = "crystal",
player = { gender = "female" } })
eq(normalized.player.name, "KRIS", "normalize fills the gendered default")
eq(Save.normalize({ version = "crystal", player = {} }).player.name, "CHRIS",
"and the male one with no byte")
-- ------------------------------------------------- the name presets
GameVersion.set("crystal")
eq(NamePick.presetsFor("male")[1], "CHRIS", "ChrisNameMenuHeader row 1")
eq(NamePick.presetsFor("male")[4], "JON", "and row 4")
eq(NamePick.presetsFor("female")[1], "KRIS", "KrisNameMenuHeader row 1")
eq(NamePick.presetsFor("female")[4], "JODI", "and row 4")
eq(NamePick.presetsFor()[1], "CHRIS", "no gender is the male header")
GameVersion.set("gold")
eq(NamePick.presetsFor("female")[1], "GOLD",
"Gold keeps PlayerNameArray whatever is asked for")
GameVersion.set("silver")
eq(NamePick.presetsFor("female")[1], "SILVER", "and so does Silver")
GameVersion.set("crystal")
eq(NamingScreen.playerSprite("female"), "SPRITE_KRIS",
"GetPlayerIcon's female sheet")
eq(NamingScreen.playerSprite("male"), "SPRITE_CHRIS", "and its male one")
-- ------------------------------------------------- the screen itself
check(Screens.GEN2_IDS ~= nil, "the registry lists the Gen 2 screens")
local registered = {}
for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end
check(registered.Gen2GenderSelect, "Gen2GenderSelect has an id")
eq(Screens.get({ data = {} }, "Gen2GenderSelect"), GenderSelect,
"and resolves to the builtin")
eq(GenderSelect.OPTIONS[1].label, "Boy", ".MenuData item 1")
eq(GenderSelect.OPTIONS[1].gender, "male", "writes wPlayerGender 0")
eq(GenderSelect.OPTIONS[2].label, "Girl", ".MenuData item 2")
eq(GenderSelect.OPTIONS[2].gender, "female", "writes wPlayerGender 1")
-- A joypad that answers one press then goes quiet, the way the harness fakes
-- input for every other screen suite here.
local function fakeInput(button)
local pressed = button
return {
wasPressed = function(_, name)
if pressed and name == pressed then
pressed = nil
return true
end
return false
end,
}
end
local function screenGame(save, button)
return { save = save, data = {}, input = fakeInput(button) }
end
local save = Save.newGame({})
local answered
local game = screenGame(save, "a")
local screen = GenderSelect.new(game, { onDone = function(g) answered = g end })
eq(screen.cursor, 1, "`db 1 ; default option` opens on Boy")
screen:update(0)
eq(save.player.gender, "male", "A on Boy writes PLAYERGENDER_MALE")
eq(answered, nil, "and the DelayFrames 10 has not run out yet")
for _ = 1, 10 do screen:update(0) end
eq(answered, "male", "onDone fires once the ten frames are gone")
save = Save.newGame({})
game = screenGame(save, "down")
screen = GenderSelect.new(game, { onDone = function() end })
screen:update(0)
eq(screen.cursor, 2, "down walks to Girl")
game.input = fakeInput("down")
screen:update(0)
eq(screen.cursor, 1, "STATICMENU_WRAP wraps at the bottom")
game.input = fakeInput("up")
screen:update(0)
eq(screen.cursor, 2, "and at the top")
game.input = fakeInput("b")
screen:update(0)
eq(save.player.gender, "male", "STATICMENU_DISABLE_B: B answers nothing")
game.input = fakeInput("a")
screen:update(0)
eq(save.player.gender, "female", "A on Girl writes PLAYERGENDER_FEMALE")
check(type(screen.text) == "string" and screen.text:find("boy"),
"the prompt is _AreYouABoyOrAreYouAGirlText")
-- ------------------------------------------------- the beat in Oak's speech
local function speechFor(sprites)
return { game = { data = { gen2Sprites = sprites } } }
end
local crystalSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_KRIS = {} }))
eq(crystalSteps[1].id, "gender_select",
"PlayerProfileSetup's InitGender runs before OakSpeech")
eq(crystalSteps[1].kind, "gender", "as its own step kind")
eq(crystalSteps[2].id, "init_clock", "and the clock follows it")
local goldSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_CHRIS = {} }))
eq(goldSteps[1].id, "init_clock", "Gold opens on the clock, as it always did")
for _, step in ipairs(goldSteps) do
check(step.kind ~= "gender", "and never grows a gender beat")
end
eq(OakSpeech.defaultSteps()[1].id, "init_clock",
"a bare call (no speech) is the Gold list")
eq(#goldSteps + 1, #crystalSteps, "the Crystal list is the Gold list plus one")
-- ------------------------------------------------- the trainer card
local CARD_GFX = {
card = "card.png", cardFemale = "card_f.png", cardTilesWide = 16,
leaderClasses = { "FALKNER" },
-- the male attrmap the extractor writes: portrait on $0, corner on $1
paletteZones = { { 14, 1, 5, 7, 1 }, { 18, 1, 1, 1, 2 } },
}
local function cardFor(gender)
return TrainerCard.new({ data = {} }, {
save = { player = { gender = gender, badges = {}, kantoBadges = {} } },
menuGfx = { trainerCard = CARD_GFX },
palettes = { trainers = { PLAYER = { { 1, 1, 1 }, { 2, 2, 2 } },
FALKNER = { { 3, 3, 3 }, { 4, 4, 4 } } } },
})
end
local male = cardFor("male")
check(not male.female, "Chris keeps ChrisCardPic")
eq(male.zoneDefault, 2, "and the border wears Falkner's row")
eq(male.zone[1 * 20 + 14], 1, "with the portrait on the player's")
local female = cardFor("female")
check(female.female, "Kris takes KrisCardPic")
eq(female.zoneDefault, 1, "the border swaps to Chris's row")
eq(female.zone[1 * 20 + 14], 2, "the portrait to Falkner's, which is Kris's")
eq(female.zone[1 * 20 + 18], 1, "the top-right corner follows the border")
eq(female.zone[14 * 20 + 14], 2, "and Clair borrows Kris's palette")
eq(female.zone[14 * 20 + 10], nil, "Pryce's box is untouched")
local noFemaleArt = TrainerCard.new({ data = {} }, {
save = { player = { gender = "female" } },
menuGfx = { trainerCard = { card = "card.png", paletteZones = {} } },
})
check(not noFemaleArt.female,
"a Gold cache with no cardFemale draws the card it has")
-- ------------------------------------------------- the Pokegear
local GEAR_GFX = {
tiles = "gear.png",
palettes = { { { 1, 1, 1 } } },
palettesFemale = { { { 9, 9, 9 } } },
}
local function gearFor(gender)
return Pokegear.new({ data = {} }, {
save = { player = { gender = gender }, pokegearFlags = {} },
menuGfx = { pokegear = GEAR_GFX },
})
end
eq(gearFor("male"):pals(), GEAR_GFX.palettes, "MalePokegearPals for Chris")
eq(gearFor("female"):pals(), GEAR_GFX.palettesFemale,
"FemalePokegearPals for Kris")
local noFemalePals = Pokegear.new({ data = {} }, {
save = { player = { gender = "female" }, pokegearFlags = {} },
menuGfx = { pokegear = { tiles = "gear.png", palettes = GEAR_GFX.palettes } },
})
eq(noFemalePals:pals(), GEAR_GFX.palettes,
"a Gold cache has one set and uses it")
-- ------------------------------------------------- the magnet train
local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide")
local SPRITES = { SPRITE_CHRIS = { image = "chris.png" },
SPRITE_KRIS = { image = "kris.png" } }
eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES },
gender = "female" }), SPRITES.SPRITE_KRIS, "Kris rides MAGNET_TRAIN_BLUE")
eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES },
gender = "male" }), SPRITES.SPRITE_CHRIS, "Chris rides MAGNET_TRAIN_RED")
eq(MagnetTrainRide.playerSpriteDef({
data = { gen2Sprites = { SPRITE_CHRIS = SPRITES.SPRITE_CHRIS } },
gender = "female" }), SPRITES.SPRITE_CHRIS,
"a Gold cache has only the one sheet")
-- ------------------------------------------------------------ cache-gated
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
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 function exists(rel)
local f = io.open(cache .. "/" .. rel, "rb")
if not f then return false end
f:close()
return true
end
local sprites = loadLua("data/generated/sprites.lua")
if not sprites then
check(true, "crystal cache absent : SKIP")
GameVersion.set(priorVersion)
S.finish()
return
end
check(sprites.SPRITE_KRIS ~= nil, "the cache carries SPRITE_KRIS")
check(sprites.SPRITE_KRIS_BIKE ~= nil, "and SPRITE_KRIS_BIKE")
eq(sprites.SPRITE_KRIS.palette, "PAL_OW_BLUE", "Kris is PAL_NPC_BLUE")
eq(sprites.SPRITE_CHRIS.palette, "PAL_OW_RED", "Chris is PAL_NPC_RED")
check(FieldMoves.hasGenderChoice(sprites),
"so a real Crystal cache offers the choice")
for _, rel in ipairs({
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
"assets/generated/trainer_card/card.png",
"assets/generated/trainer_card/card_f.png",
"assets/generated/sprites/kris.png",
"assets/generated/sprites/kris_bike.png",
}) do
check(exists(rel), "the cache carries " .. rel)
end
local menuGfx = loadLua("data/generated/menu_gfx.lua") or {}
check(menuGfx.trainerCard and menuGfx.trainerCard.cardFemale ~= nil,
"menu_gfx.trainerCard.cardFemale is extracted")
check(menuGfx.pokegear and menuGfx.pokegear.palettesFemale ~= nil,
"menu_gfx.pokegear.palettesFemale is extracted")
check(menuGfx.pack and menuGfx.pack.palettesFemale ~= nil,
"menu_gfx.pack.palettesFemale is extracted")
local palettes = loadLua("data/generated/palettes.lua") or {}
check(palettes.trainers and palettes.trainers.PLAYER ~= nil,
"PlayerPalette is row 0")
check(palettes.trainers and palettes.trainers.FALKNER ~= nil,
"and KrisPalette is Falkner's")
local romText = loadLua("data/generated/rom_text.lua") or {}
check(romText._AreYouABoyOrAreYouAGirlText ~= nil,
"the prompt is in the extracted text")
GameVersion.set(priorVersion)
S.finish()
+463
View File
@@ -0,0 +1,463 @@
-- Crystal's Kris battle/Hall-of-Fame art and the Mobile System GB sheets.
-- luajit tests/gen2_crystal_mobile_gfx_test.lua
--
-- Two imports that had no consumer at the time they landed, so nothing else
-- would notice them rotting: Kris's backpic and KRIS trainer-class pic (wave A
-- shipped the female protagonist but battles and the Hall of Fame still drew
-- Chris), and the whole gfx/mobile + gfx/mystery_gift set, imported ahead of
-- the phase that uses it so Crystal never has to re-import for it.
--
-- What is held down: the stage exists and run() calls it, it is gated on the
-- crystal edition so Gold and Silver emit nothing, every label it reads is in
-- the generated Crystal manifest at the address pokecrystal.sym gives, and the
-- blobs it wrote are ../pokecrystal/gfx/mobile byte for byte.
--
-- ROM-free. The decomp half SKIPs with no ../pokecrystal beside the repo, the
-- cache half with no Crystal cache.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal mobile gfx")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local function readFile(path)
local f = io.open(path, "rb")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
-- ------------------------------------------------------- the stage itself
local extractorSource = assert(readFile("src/import/RomExtractorGen2.lua"))
local RomExtractorGen2 = require("src.import.RomExtractorGen2")
check(type(RomExtractorGen2.extractMobileGfx) == "function",
"RomExtractorGen2:extractMobileGfx exists")
check(extractorSource:find("results.mobileGfx = self:extractMobileGfx()",
1, true) ~= nil, "and RomExtractorGen2:run calls it")
check(extractorSource:find(
'if self.edition ~= "crystal" then return nil end', 1, true) ~= nil,
"gated on the edition, so Gold and Silver write no mobile art")
-- The two suites that pin the progress bar read this same literal; a new
-- stage must not move it (tests/gen2_diploma_test.lua:45).
check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil,
"and it did not disturb STAGE_COUNT")
-- ------------------------------------------------------------ the symbols
-- ../pokecrystal-symbols/pokecrystal.sym, international v1.0. Every one of
-- these resolves in that build: the Japanese-only part of Mobile System GB is
-- the text and the menu entries that reach it, not the art.
local SYMBOLS = {
AsciiFontGFX = { 0x5c, 0x5db1 },
PichuAnimatedMobileGFX = { 0x5c, 0x4d16 },
ElectroBallMobileGFX = { 0x5c, 0x55a4 },
PichuBorderMobileGFX = { 0x5c, 0x5848 },
Stadium2N64GFX = { 0x5c, 0x6f1f },
Stadium2N64Tilemap = { 0x5c, 0x73af },
Stadium2N64Attrmap = { 0x5c, 0x7517 },
PasswordTopTilemap = { 0x5c, 0x6491 },
PasswordBottomTilemap = { 0x5c, 0x651d },
PasswordShiftTilemap = { 0x5c, 0x65f9 },
ChooseMobileCenterTilemap = { 0x5c, 0x6685 },
MobilePasswordAttrmap = { 0x5c, 0x67ed },
ChooseMobileCenterAttrmap = { 0x5c, 0x6955 },
MobilePasswordPalettes = { 0x5c, 0x5d71 },
MobileCardGFX = { 0x5e, 0x59ef },
ChrisSilhouetteGFX = { 0x5e, 0x5bef },
KrisSilhouetteGFX = { 0x5e, 0x5e1f },
MobileCard2GFX = { 0x5e, 0x604f },
CardLargeSpriteAndFolderGFX = { 0x5e, 0x61bf },
CardSpriteGFX = { 0x5e, 0x664f },
DialpadTilemap = { 0x5e, 0x6cd5 },
DialpadAttrmap = { 0x5e, 0x6e3d },
DialpadGFX = { 0x5e, 0x6fa5 },
DialpadCursorGFX = { 0x5e, 0x7465 },
MobileCardListGFX = { 0x5e, 0x74b9 },
HaveWantGFX = { 0x5f, 0x4083 },
MobileSelectGFX = { 0x5f, 0x4983 },
HaveWantMap = { 0x5f, 0x4b83 },
PokemonNewsGFX = { 0x5f, 0x66fe },
PokemonNewsTileAttrmap = { 0x5f, 0x6b8e },
PokemonNewsPalettes = { 0x5f, 0x6ff6 },
["MobileSystemSplashScreen_InitGFX.Tiles"] = { 0x5b, 0x4173 },
["MobileSystemSplashScreen_InitGFX.Tilemap"] = { 0x5b, 0x4633 },
["MobileSystemSplashScreen_InitGFX.Attrmap"] = { 0x5b, 0x479b },
MobileSplashScreenPalettes = { 0x5b, 0x4903 },
MobileAdapterCheckGFX = { 0x5b, 0x4ca3 },
MobileTradeSpritesGFX = { 0x42, 0x4d27 },
MobileTradeGFX = { 0x42, 0x4da7 },
MobileTradeTilemapLZ = { 0x42, 0x4fe7 },
MobileTradeAttrmapLZ = { 0x42, 0x50a7 },
MobileCable1GFX = { 0x42, 0x51c7 },
MobileCable2GFX = { 0x42, 0x52c7 },
UnusedMobilePulsePalettes = { 0x42, 0x50f7 },
MobileTradeBGPalettes = { 0x42, 0x5107 },
MobileTradeOB1Palettes = { 0x42, 0x5147 },
MobileTradeOB2Palettes = { 0x42, 0x5187 },
MobileAdapterPalettes = { 0x42, 0x53c7 },
MobileTradeLightsGFX = { 0x40, 0x72a2 },
MobileTradeLightsPalettes = { 0x40, 0x72e2 },
PichuBorderMobileOBPalettes = { 0x45, 0x730e },
PichuBorderMobileBGPalettes = { 0x45, 0x734e },
PichuBorderMobileTilemapAttrmap = { 0x45, 0x7356 },
MobileDialingGFX = { 0x45, 0x601a },
MobileUpArrowGFX = { 0x12, 0x48c3 },
MobileDownArrowGFX = { 0x12, 0x48cb },
EZChatCursorGFX = { 0x22, 0x540b },
MobileDialingFrameGFX = { 0x41, 0x6514 },
SelectStartGFX = { 0x47, 0x567e },
MobileMenuGFX = { 0x12, 0x5c0c },
MobilePhoneTilesGFX = { 0x3e, 0x5234 },
MysteryGiftGFX = { 0x41, 0x5258 },
CardTradeGFX = { 0x41, 0x5930 },
CardTradeSpriteGFX = { 0x41, 0x5d30 },
}
-- Kris's own two pics. KrisBackpic needs no delta row: embedded_symbols in
-- make_gold_manifest.py adds every unqualified *Backpic label on sight.
local KRIS_SYMBOLS = {
KrisBackpic = { 0x22, 0x4ed6 },
KrisPic = { 0x22, 0x4bb9 },
ChrisPic = { 0x22, 0x48a9 },
}
do
local deltas = assert(readFile("tools/crystal_symbol_deltas.py"))
for label in pairs(SYMBOLS) do
check(deltas:find('"' .. label .. '"', 1, true) ~= nil,
label .. " is in crystal_symbol_deltas.MOBILE_SYMBOLS")
end
end
local Json = require("src.link.Json")
local function manifestSymbols(path)
local body = readFile(path)
if not body then return nil end
return (Json.decode(body) or {}).symbols or {}
end
do
local symbols = manifestSymbols("tools/rom_manifest_crystal.json")
if not symbols then
check(true, "no tools/rom_manifest_crystal.json (SKIP)")
else
local wrong = {}
for label, at in pairs(SYMBOLS) do
local got = symbols[label]
if not got or got[1] ~= at[1] or got[2] ~= at[2] then
wrong[#wrong + 1] = label
end
end
check(#wrong == 0, #wrong == 0
and "the Crystal manifest carries all 63 mobile symbols where "
.. "pokecrystal.sym puts them"
or ("mobile symbols missing or misplaced: " .. table.concat(wrong, ", ")))
for label, at in pairs(KRIS_SYMBOLS) do
local got = symbols[label]
if not got then
check(false, "the Crystal manifest carries " .. label)
else
eq(got[1], at[1], label .. " is in the bank pokecrystal.sym says")
eq(got[2], at[2], label .. " is at the address it says")
end
end
end
end
do
-- Gold has no mobile banks; a delta that leaked into the shared set would
-- break the Gold import rather than show up here as a missing sheet.
local symbols = manifestSymbols("tools/rom_manifest_gold.json")
if not symbols then
check(true, "no tools/rom_manifest_gold.json (SKIP)")
else
local leaked = {}
for label in pairs(SYMBOLS) do
if symbols[label] then leaked[#leaked + 1] = label end
end
check(#leaked == 0, #leaked == 0
and "and the Gold manifest carries none of them"
or ("mobile symbols leaked into Gold: " .. table.concat(leaked, ", ")))
check(symbols.KrisBackpic == nil, "nor Gold's non-existent KrisBackpic")
end
end
-- --------------------------------------------------------------- the cache
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local function loadCache(rel)
local chunk = loadfile(cache .. "/data/generated/" .. rel .. ".lua")
if not chunk then return nil end
local ok, value = pcall(chunk)
return ok and value or nil
end
-- PNG IHDR: width and height are big-endian at bytes 17 and 21.
local function pngSize(relative)
local png = readFile(cache .. "/" .. relative)
if not png then return nil end
local function be32(s, i)
local a, b, c, d = s:byte(i, i + 3)
return ((a * 256 + b) * 256 + c) * 256 + d
end
return be32(png, 17), be32(png, 21)
end
-- ../pokecrystal/gfx/player/kris_back.png is 48x48 and kris.png 56x56, the
-- same six and seven tiles square Chris uses.
local BACK_PIC_PX = 48
local TRAINER_PIC_PX = 56
-- Kris's art and the mobile sheets ride one import, so mobile_gfx.lua doubles
-- as the marker for both: a cache built before this unit has neither, and the
-- whole cache half stands down rather than failing on an old sandbox.
local mobile = loadCache("mobile_gfx")
local menuGfx = mobile and loadCache("menu_gfx") or nil
if not menuGfx then
check(true, "no Crystal cache with mobile_gfx.lua at " .. cache
.. " : re-import for gfx/mobile (SKIP)")
else
local hud = menuGfx.battleHud or {}
eq(hud.playerBackFemale, "assets/generated/battle/player_back_female.png",
"menu_gfx points battles and the Hall of Fame at Kris's backpic")
local w, h = pngSize("assets/generated/battle/player_back_female.png")
eq(w, BACK_PIC_PX, "GetKrisBackpic's pic is 48px wide")
eq(h, BACK_PIC_PX, "and 48 tall, not the 7*7 tiles of VRAM it asks for")
check(hud.playerBack ~= hud.playerBackFemale,
"and it is not the same file Chris gets")
local pics = hud.trainerPics or {}
eq(pics.KRIS, "assets/generated/battle/trainers/kris.png",
"HOF_LoadTrainerFrontpic's KRIS class has a pic of its own")
eq(pics.CHRIS, "assets/generated/battle/trainers/chris.png",
"as does CHRIS, which TrainerPicPointers has no row for")
for _, key in ipairs({ "kris", "chris" }) do
local pw, ph = pngSize("assets/generated/battle/trainers/" .. key .. ".png")
eq(pw, TRAINER_PIC_PX, key .. ".png is 56px wide")
eq(ph, TRAINER_PIC_PX, "and 56 tall, like every other trainer pic")
end
end
local oakSpeech = mobile and loadCache("oak_speech") or nil
if not oakSpeech then
check(true, "no oak_speech.lua beside a mobile_gfx.lua cache (SKIP)")
else
eq(oakSpeech.playerPicFemale, "assets/generated/intro/kris.png",
"and DrawIntroPlayerPic's female arm is named in oak_speech.lua")
end
-- Sheets whose tile count fills the pret PNG's grid exactly once padded, so
-- the cache PNG must come out at the source PNG's own size. The three left
-- out (mobile_splash, electro_ball, phone_tiles) are built with
-- --remove-duplicates / --remove-whitespace, so the ROM holds fewer tiles than
-- the PNG does and only the tilemap puts them back (../pokecrystal/Makefile:
-- 343,344,348).
local SHEETS = {
{ "asciiFont", "mobile/ascii_font.png", 110, 16, "gfx/mobile/ascii_font.png" },
{ "card", "mobile/card.png", 32, 16, "gfx/mobile/card.png" },
{ "card2", "mobile/card_2.png", 23, 12, "gfx/mobile/card_2.png" },
{ "cardLargeSprite", "mobile/card_large_sprite.png", 8, 4,
"gfx/mobile/card_large_sprite.png" },
{ "cardFolder", "mobile/card_folder.png", 65, 6,
"gfx/mobile/card_folder.png" },
{ "cardList", "mobile/card_list.png", 24, 12, "gfx/mobile/card_list.png" },
{ "cardSprite", "mobile/card_sprite.png", 4, 2, "gfx/mobile/card_sprite.png" },
{ "chrisSilhouette", "mobile/chris_silhouette.png", 35, 5,
"gfx/mobile/chris_silhouette.png" },
{ "krisSilhouette", "mobile/kris_silhouette.png", 35, 5,
"gfx/mobile/kris_silhouette.png" },
{ "dialing", "mobile/dialing.png", 20, 2, "gfx/mobile/dialing.png" },
{ "dialingFrame", "mobile/dialing_frame.png", 8, 2,
"gfx/mobile/dialing_frame.png" },
{ "dialpad", "mobile/dialpad.png", 76, 16, "gfx/mobile/dialpad.png" },
{ "dialpadCursor", "mobile/dialpad_cursor.png", 5, 2,
"gfx/mobile/dialpad_cursor.png" },
{ "ezChatCursor", "mobile/ez_chat_cursor.png", 2, 1,
"gfx/mobile/ez_chat_cursor.png" },
{ "haveWant", "mobile/havewant.png", 144, 16, "gfx/mobile/havewant.png" },
{ "select", "mobile/select.png", 32, 4, "gfx/mobile/select.png" },
{ "selectStart", "mobile/select_start.png", 6, 3,
"gfx/mobile/select_start.png" },
{ "cable1", "mobile/mobile_cable_1.png", 16, 4,
"gfx/mobile/mobile_cable_1.png" },
{ "cable2", "mobile/mobile_cable_2.png", 16, 4,
"gfx/mobile/mobile_cable_2.png" },
{ "menu", "mobile/mobile_menu.png", 13, 13, "gfx/mobile/mobile_menu.png" },
{ "adapterCheck", "mobile/mobile_splash_check.png", 48, 16,
"gfx/mobile/mobile_splash_check.png" },
{ "tradeLights", "mobile/mobile_trade_lights.png", 4, 2,
"gfx/mobile/mobile_trade_lights.png" },
{ "pichuBorder", "mobile/pichu_border.png", 24, 4,
"gfx/mobile/pichu_border.png" },
{ "pokemonNews", "mobile/pokemon_news.png", 72, 8,
"gfx/mobile/pokemon_news.png" },
{ "stadium2N64", "mobile/stadium2_n64.png", 73, 11,
"gfx/mobile/stadium2_n64.png" },
{ "pichuAnimated", "mobile/pichu_animated.png", 193, 16,
"gfx/mobile/pichu_animated.png" },
{ "trade", "mobile/mobile_trade.png", 128, 8, "gfx/mobile/mobile_trade.png" },
{ "tradeSprites", "mobile/mobile_trade_sprites.png", 16, 4,
"gfx/mobile/mobile_trade_sprites.png" },
{ "upArrow", "mobile/up_arrow.png", 1, 1, "gfx/mobile/up_arrow.png" },
{ "downArrow", "mobile/down_arrow.png", 1, 1, "gfx/mobile/down_arrow.png" },
{ "mysteryGift", "mobile/mystery_gift/mystery_gift.png", 67, 16,
"gfx/mystery_gift/mystery_gift.png" },
{ "cardTrade", "mobile/mystery_gift/card_trade.png", 64, 16,
"gfx/mystery_gift/card_trade.png" },
{ "cardTradeSprite", "mobile/mystery_gift/card_sprite.png", 8, 4,
"gfx/mystery_gift/card_sprite.png" },
}
-- The three the dedupe passes shrink: only the declared tile count is checked.
local DEDUPED = {
{ "splash", "mobile/mobile_splash.png", 76, 14 },
{ "electroBall", "mobile/electro_ball.png", 83, 16 },
{ "phoneTiles", "mobile/phone_tiles.png", 17, 2 },
}
-- key, cache file, byte length, ../pokecrystal source it must equal.
local MAPS = {
{ "passwordTop", "password_top", 140, "gfx/mobile/password_top.tilemap" },
{ "passwordBottom", "password_bottom", 220,
"gfx/mobile/password_bottom.tilemap" },
{ "passwordShift", "password_shift", 140,
"gfx/mobile/password_shift.tilemap" },
{ "passwordAttrmap", "password_attrmap", 360,
"gfx/mobile/password.attrmap" },
{ "centerTilemap", "mobile_center_tilemap", 360,
"gfx/mobile/mobile_center.tilemap" },
{ "centerAttrmap", "mobile_center_attrmap", 360,
"gfx/mobile/mobile_center.attrmap" },
{ "stadium2N64Tilemap", "stadium2_n64_tilemap", 360,
"gfx/mobile/stadium2_n64.tilemap" },
{ "stadium2N64Attrmap", "stadium2_n64_attrmap", 360,
"gfx/mobile/stadium2_n64.attrmap" },
{ "dialpadTilemap", "dialpad_tilemap", 360, "gfx/mobile/dialpad.tilemap" },
{ "dialpadAttrmap", "dialpad_attrmap", 360, "gfx/mobile/dialpad.attrmap" },
{ "haveWantMap", "havewant_map", 1136, "gfx/mobile/havewant_map.bin" },
{ "newsAttrmap", "pokemon_news_attrmap", 1128,
"gfx/mobile/pokemon_news.bin" },
{ "splashTilemap", "mobile_splash_tilemap", 360,
"gfx/mobile/mobile_splash.tilemap" },
{ "splashAttrmap", "mobile_splash_attrmap", 360,
"gfx/mobile/mobile_splash.attrmap" },
{ "pichuBorderTilemap", "pichu_border_tilemap", 384,
"gfx/mobile/pichu_border.tilemap" },
{ "pichuBorderAttrmap", "pichu_border_attrmap", 384,
"gfx/mobile/pichu_border.attrmap" },
{ "tradeTilemap", "mobile_trade_tilemap", 1024,
"gfx/mobile/mobile_trade.tilemap" },
{ "tradeAttrmap", "mobile_trade_attrmap", 1024,
"gfx/mobile/mobile_trade.attrmap" },
}
local PALETTE_COLORS = {
splash = 32, password = 32, pokemonNews = 32, pichuBorderOB = 32,
pichuBorderBG = 4, tradeBG = 32, tradeOB1 = 32, tradeOB2 = 32,
tradeLights = 16, adapters = 8, unusedPulses = 8,
}
local pokecrystal = "../pokecrystal"
local havePret = readFile(pokecrystal .. "/gfx/mobile/ascii_font.2bpp") ~= nil
if not mobile then
check(true, "no mobile_gfx.lua in the Crystal cache (SKIP)")
else
eq(mobile.generation, 2, "mobile_gfx.lua is a Gen 2 table")
local sheets = mobile.sheets or {}
local maps = mobile.maps or {}
local palettes = mobile.palettes or {}
local function checkSheet(row, pretPng)
local key, rel, tiles, wide = row[1], row[2], row[3], row[4]
local entry = sheets[key]
if type(entry) ~= "table" then
check(false, "mobile_gfx carries the " .. key .. " sheet")
return
end
eq(entry.tiles, tiles, key .. " is the tile count the ROM block holds")
eq(entry.tilesWide, wide, "and is written " .. wide .. " tiles across")
eq(entry.path, "assets/generated/" .. rel, "at the path it advertises")
local w, h = pngSize("assets/generated/" .. rel)
eq(w, wide * 8, key .. ".png is " .. (wide * 8) .. "px wide")
eq(h, math.ceil(tiles / wide) * 8, "and tall enough for every tile")
if pretPng and havePret then
local png = readFile(pokecrystal .. "/" .. pretPng)
if png then
local function be32(s, i)
local a, b, c, d = s:byte(i, i + 3)
return ((a * 256 + b) * 256 + c) * 256 + d
end
eq(w, be32(png, 17), "which is " .. pretPng .. "'s own width")
eq(h, be32(png, 21), "and its own height")
end
end
end
for _, row in ipairs(SHEETS) do checkSheet(row, row[5]) end
for _, row in ipairs(DEDUPED) do checkSheet(row, nil) end
for _, row in ipairs(MAPS) do
local key, file, bytes, source = row[1], row[2], row[3], row[4]
local entry = maps[key]
local rel = "assets/generated/mobile/" .. file .. ".bin"
if type(entry) ~= "table" then
check(false, "mobile_gfx carries the " .. key .. " map")
else
eq(entry.bytes, bytes, key .. " is " .. bytes .. " bytes")
eq(entry.path, rel, "at the path it advertises")
local blob = readFile(cache .. "/" .. rel)
if not blob then
check(false, rel .. " was written")
else
eq(#blob, bytes, "and the file on disk is that long")
local want = havePret and readFile(pokecrystal .. "/" .. source) or nil
if not want then
check(true, "no ../pokecrystal: " .. source .. " not diffed (SKIP)")
else
check(blob == want:sub(1, bytes),
"it is ../pokecrystal/" .. source .. " byte for byte")
end
end
end
end
for key, colors in pairs(PALETTE_COLORS) do
local pal = palettes[key]
if type(pal) ~= "table" then
check(false, "mobile_gfx carries the " .. key .. " palette block")
else
eq(#pal, colors, key .. " is " .. colors .. " RGB words")
eq(#(pal[1] or {}), 3, "each one decoded to an r,g,b triple")
end
end
end
-- A Gold cache must have gone nowhere near any of this.
local goldCache = os.getenv("GOLD_CACHE")
if not goldCache then
local home = os.getenv("HOME") or ""
goldCache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
if not loadfile(goldCache .. "/data/generated/menu_gfx.lua") then
check(true, "no Gold cache at " .. goldCache .. " (SKIP)")
else
check(loadfile(goldCache .. "/data/generated/mobile_gfx.lua") == nil,
"a Gold cache has no mobile_gfx.lua")
local chunk = loadfile(goldCache .. "/data/generated/menu_gfx.lua")
local ok, gold = pcall(chunk)
local hud = (ok and gold or {}).battleHud or {}
check(hud.playerBackFemale == nil, "and no Kris backpic")
check((hud.trainerPics or {}).KRIS == nil, "and no KRIS trainer pic")
end
S.finish()
+555
View File
@@ -0,0 +1,555 @@
-- 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()
+421
View File
@@ -0,0 +1,421 @@
-- The wall map, the printed diploma and the memory game: the three specials
-- both Gen 2 carts carry that had no port half (engine/events/specials.asm:100
-- OverworldTownMap, :448 PrintDiploma, :207 UnusedMemoryGame).
--
-- OverworldTownMap has two callers. TownMapScript
-- (engine/events/std_scripts.asm:145) is registered but no map jumpstd's it on
-- either cart; DecorationDesc_TownMapPoster
-- (engine/overworld/decorations.asm:1005) is the live one, and InitDecorations
-- (engine/overworld/decorations.asm:1, run from
-- engine/menus/intro_menu.asm:133) hangs DECO_TOWN_MAP on the bedroom wall at
-- new game, so a silent no-op here is a dead end in the room the player starts
-- in.
-- luajit tests/gen2_crystal_townmap_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal town map")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local Events = require("src.world.gen2.Events")
local Pokegear = require("src.ui.gen2.Pokegear")
local Specials = require("src.script.gen2.Specials")
local Vm = require("src.script.gen2.Vm")
local H = Specials.HANDLERS
local function fakeInput()
local pressed = {}
return {
press = function(_self, button) pressed[button] = true end,
wasPressed = function(_self, button)
if pressed[button] then
pressed[button] = nil
return true
end
return false
end,
}
end
-- landmarks.lua's shape. ../pokecrystal/constants/landmark_constants.asm:34
-- inserts LANDMARK_BATTLE_TOWER, so every index from ROUTE_40 up is one higher
-- than pokegold/constants/landmark_constants.asm gives it; both index spaces
-- are built here because the two limit registers are read out of this table.
local function landmarkTable(rows)
local out = { landmarks = {}, order = {} }
for id, index in pairs(rows) do
out.landmarks[id] = {
id = id, index = index,
name = id:gsub("^LANDMARK_", ""):gsub("_", " "), x = 8, y = 8,
}
out.order[index + 1] = id
end
return out
end
local GOLD_LANDMARKS = landmarkTable({
LANDMARK_NEW_BARK_TOWN = 0x01,
LANDMARK_ROUTE_29 = 0x02,
LANDMARK_SILVER_CAVE = 0x2d,
LANDMARK_PALLET_TOWN = 0x2e,
LANDMARK_VICTORY_ROAD = 0x57,
LANDMARK_ROUTE_28 = 0x5d,
LANDMARK_FAST_SHIP = 0x5e,
})
local CRYSTAL_LANDMARKS = landmarkTable({
LANDMARK_NEW_BARK_TOWN = 0x01,
LANDMARK_ROUTE_29 = 0x02,
LANDMARK_BATTLE_TOWER = 0x1d,
LANDMARK_SILVER_CAVE = 0x2e,
LANDMARK_PALLET_TOWN = 0x2f,
LANDMARK_VICTORY_ROAD = 0x58,
LANDMARK_ROUTE_28 = 0x5e,
LANDMARK_FAST_SHIP = 0x5f,
})
local function townMapScreen(opts)
opts = opts or {}
local input = fakeInput()
local save = opts.save or {}
local popped = 0
local game = {
input = input,
save = save,
stack = { pop = function() popped = popped + 1 end },
}
local closed = 0
local screen = Pokegear.new(game, {
save = save,
landmarks = opts.landmarks or CRYSTAL_LANDMARKS,
currentLandmark = opts.currentLandmark or "LANDMARK_NEW_BARK_TOWN",
townMap = true,
onClose = function() closed = closed + 1 end,
})
return screen, input,
function() return closed end, function() return popped end
end
-- ================================================= 1. the screen's own mode
--
-- _TownMap (engine/pokegear/pokegear.asm:1709) reuses PokegearMap for the art
-- and nothing else off the card: no strip, no ENGINE_MAP_CARD test.
do
local screen = townMapScreen()
eq(#screen.cards, 1, "the poster is one screen, not the card strip")
eq(screen.mode, "card", "and it opens straight onto the map")
eq(screen:card().id, "map", "on the MAP card's own art")
eq(screen.fly, nil, "and it is not the fly picker")
end
-- The card gate is _FlyMap's absence, not the gear's: a player who has never
-- met the Guide Gent still reads the poster in their bedroom.
do
local screen = townMapScreen({ save = { engineFlags = {} } })
eq(#screen.cards, 1, "no ENGINE_MAP_CARD is needed to open it")
end
-- ================================================= 2. the joypad
--
-- `.loop` (engine/pokegear/pokegear.asm:1783): PAD_B returns, PAD_UP and
-- PAD_DOWN walk wTownMapCursorLandmark, and nothing else is read at all.
do
local screen, input, closed, popped = townMapScreen()
local first = CRYSTAL_LANDMARKS.landmarks.LANDMARK_NEW_BARK_TOWN.index
local last = CRYSTAL_LANDMARKS.landmarks.LANDMARK_SILVER_CAVE.index
eq(screen:mapCursorIndex(), first, "the cursor starts on the player's own")
input:press("up")
screen:update(0)
eq(screen:mapCursorIndex(), first + 1, "UP steps to the next landmark")
input:press("down")
screen:update(0)
eq(screen:mapCursorIndex(), first, "DOWN steps back")
-- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps.
input:press("down")
screen:update(0)
eq(screen:mapCursorIndex(), last, "and wraps off the first onto the last")
-- `cp d / jr c, .wrap_around_up`.
input:press("up")
screen:update(0)
eq(screen:mapCursorIndex(), first, "and off the last back onto the first")
-- Left, right and A are never read by _TownMap's loop.
local held = screen:mapCursorIndex()
for _, button in ipairs({ "left", "right", "a", "start", "select" }) do
input:press(button)
screen:update(0)
end
eq(screen:mapCursorIndex(), held, "no other button moves the cursor")
eq(closed(), 0, "and none of them leaves the map")
input:press("b")
screen:update(0)
eq(closed(), 1, "B returns from the map")
eq(popped(), 1, "and the screen the special pushed comes back off the stack")
-- The pop already happened; a second B must not take the overworld with it.
input:press("b")
screen:update(0)
eq(popped(), 1, "a second B pops nothing")
end
-- ================================================= 3. the limit registers
--
-- `ld d, KANTO_LANDMARK - 1 / ld e, 1` for Johto and
-- TownMap_GetKantoLandmarkLimits (engine/pokegear/pokegear.asm:714) for Kanto.
-- Both are LANDMARK indices, and Crystal's are not Gold's.
do
local crystal = townMapScreen({ landmarks = CRYSTAL_LANDMARKS })
local last, first = crystal:cursorLimits()
eq(first, 0x01, "Johto's e is NEW BARK TOWN either way round")
eq(last, 0x2e, "and its d is Crystal's own SILVER CAVE, not Gold's $2d")
local gold = townMapScreen({ landmarks = GOLD_LANDMARKS })
local goldLast = gold:cursorLimits()
eq(goldLast, 0x2d, "a Gold landmark table still answers $2d")
-- A dataset with no landmark records at all keeps the pokegold numbers.
local bare = townMapScreen({ landmarks = { landmarks = {}, order = {} } })
local bareLast, bareFirst = bare:cursorLimits()
eq(bareLast, 0x2d, "and so does a dataset with no records")
eq(bareFirst, 0x01, "with NEW BARK TOWN still the floor")
end
-- `cp KANTO_LANDMARK` is the region test, and KANTO_LANDMARK is PALLET_TOWN's
-- own index: $2e on Gold, $2f on Crystal. The byte $2e therefore reads as
-- Kanto on one cart and Johto (SILVER CAVE) on the other.
do
local silver = townMapScreen({
landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_SILVER_CAVE",
})
eq(silver:region(), "johto", "Crystal's $2e is SILVER CAVE, so Johto")
local pallet = townMapScreen({
landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN",
})
eq(pallet:region(), "kanto", "and its $2f is PALLET TOWN, so Kanto")
local goldPallet = townMapScreen({
landmarks = GOLD_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN",
})
eq(goldPallet:region(), "kanto", "Gold's PALLET TOWN is Kanto at $2e")
-- LANDMARK_FAST_SHIP sits past every Kanto landmark and is still Johto.
local ship = townMapScreen({
landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_FAST_SHIP",
})
eq(ship:region(), "johto", "and the S.S. Aqua stays Johto at $5f")
end
-- Kanto's pair, with and without the Hall of Fame on the record.
do
local before = townMapScreen({
landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN",
save = { flags = {} },
})
local lastB, firstB = before:cursorLimits()
eq(firstB, 0x58, "before the Hall of Fame Kanto starts at VICTORY ROAD")
eq(lastB, 0x5e, "and ends at ROUTE 28")
local after = townMapScreen({
landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN",
save = { flags = { HALL_OF_FAME = true } },
})
local _, firstA = after:cursorLimits()
eq(firstA, 0x2f, "and afterwards the whole region opens at PALLET TOWN")
end
-- ================================================= 4. the tilemap frame
--
-- _TownMap.InitTilemap (engine/pokegear/pokegear.asm:1843): the rule turns down
-- at (7,0) and runs back out along row 2, which is what boxes the name plate
-- into the top right corner with no card strip above it.
do
local screen = townMapScreen()
local laid = {}
screen.tile = function(_self, id, tx, ty)
laid[tx .. "," .. ty] = id
end
screen:drawTownMapRule()
eq(laid["0,0"], 0x06, "$06 caps the rule at (0,0)")
local run = true
for x = 1, 6 do run = run and laid[x .. ",0"] == 0x07 end
check(run, "$07 runs (1,0) to (6,0), the `ld bc, 6` ByteFill")
eq(laid["7,0"], 0x17, "$17 turns it down at (7,0)")
eq(laid["7,1"], 0x16, "$16 carries it through (7,1)")
eq(laid["7,2"], 0x26, "$26 turns it back out at (7,2)")
local tail = true
for x = 8, 18 do tail = tail and laid[x .. ",2"] == 0x07 end
check(tail, "$07 runs (8,2) to (18,2), the NAME_LENGTH ByteFill")
eq(laid["19,2"], 0x17, "$17 caps it at (19,2)")
eq(laid["8,1"], nil, "and nothing is laid inside the name plate")
-- The MAP card's own rule is the other shape: a full-width bar under the
-- two-row card strip. The poster must not draw that.
local card = Pokegear.new({ input = fakeInput(), save = {} }, {
save = { pokegearFlags = { map = true } },
landmarks = CRYSTAL_LANDMARKS,
currentLandmark = "LANDMARK_NEW_BARK_TOWN",
})
eq(card.townMap, nil, "the POKeGEAR card is not in town-map mode")
check(#card.cards > 1, "and it keeps its card strip")
end
-- Drawing with no town-map art at all: a crash check on the unstyled fallback.
do
local screen = townMapScreen()
check(not screen:styled(), "this harness has no gear sheet")
check(pcall(function() screen:draw() end), "the poster still draws")
end
-- ================================================= 5. the specials
--
-- World:specialHooks, stubbed down to what these two reach for.
local function newHooks(opts)
opts = opts or {}
local log = { pushed = {}, diploma = 0 }
local hooks = {
log = log,
save = function() return opts.save or {} end,
data = function() return {} end,
party = function() return {} end,
}
if not opts.noScreens then
hooks.pushScreen = function(id, screenOpts)
log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts }
if opts.pushFails then return false end
log.pending = screenOpts.onClose
if not opts.hold and screenOpts.onClose then screenOpts.onClose() end
return true
end
hooks.showDiploma = function(onDone)
log.diploma = log.diploma + 1
log.pending = onDone
if not opts.hold and onDone then onDone() end
end
end
return hooks
end
local function newVm(hooks, scriptVar)
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
vm.showTextFn = function() end
vm.scriptVar = scriptVar or 0
return vm
end
-- Run a handler to its first park (or to the end), the way the VM runs it.
local function run(vm, handler)
local co = coroutine.create(function() handler(vm) end)
vm.co = co
local ok, req = coroutine.resume(co)
if not ok then error(req, 0) end
return co, req
end
-- ---- OverworldTownMap ------------------------------------------------------
check(H.OverworldTownMap ~= nil, "OverworldTownMap has a handler")
eq(Specials.STUBS.OverworldTownMap, nil, "and is no longer a stub")
check(Specials.SUPERSEDED_STUBS.OverworldTownMap ~= nil,
"its old reason moved to the superseded ledger")
do
local hooks = newHooks()
local vm = newVm(hooks, 7)
local co = run(vm, H.OverworldTownMap)
eq(coroutine.status(co), "dead", "an answering screen runs it straight out")
eq(#hooks.log.pushed, 1, "one screen is pushed")
eq(hooks.log.pushed[1].id, "Gen2Pokegear", "which is the POKeGEAR's own map")
eq(hooks.log.pushed[1].opts.townMap, true, "in _TownMap's chrome-free mode")
-- FadeToMenu / farcall _TownMap / ExitAllMenus writes no wScriptVar, and the
-- callers' next op is a bare `closetext`.
eq(vm.scriptVar, 7, "and wScriptVar is left exactly as `special` found it")
end
-- The block is real: the coroutine parks until the screen answers.
do
local hooks = newHooks({ hold = true })
local vm = newVm(hooks, 0)
local co = run(vm, H.OverworldTownMap)
eq(coroutine.status(co), "suspended", "the script waits on the map")
hooks.log.pending()
eq(coroutine.status(co), "dead", "and only B off the map resumes it")
end
-- A run with no screen to push (a headless probe) must still not fall through
-- into the next command with a stale wScriptVar.
do
local hooks = newHooks({ pushFails = true })
local vm = newVm(hooks, 3)
local co = run(vm, H.OverworldTownMap)
eq(coroutine.status(co), "dead", "a refused push does not hang the script")
eq(vm.scriptVar, 3, "and still leaves wScriptVar alone")
end
do
local hooks = newHooks({ noScreens = true })
local vm = newVm(hooks, 3)
local co = run(vm, H.OverworldTownMap)
eq(coroutine.status(co), "dead", "and neither does no pushScreen hook")
eq(vm.scriptVar, 3, "with wScriptVar untouched")
end
-- ---- PrintDiploma ----------------------------------------------------------
check(H.PrintDiploma ~= nil, "PrintDiploma has a handler")
eq(Specials.STUBS.PrintDiploma, nil, "and is no longer a stub")
check(Specials.SUPERSEDED_STUBS.PrintDiploma ~= nil,
"its printer reason moved to the superseded ledger")
do
local hooks = newHooks()
local vm = newVm(hooks, 5)
local co = run(vm, H.PrintDiploma)
eq(coroutine.status(co), "dead", "the page runs out")
eq(hooks.log.diploma, 1, "PlaceDiplomaOnScreen is the routine's first act")
eq(vm.scriptVar, 5, "and _PrintDiploma writes no wScriptVar either")
end
do
local hooks = newHooks({ hold = true })
local vm = newVm(hooks, 0)
local co = run(vm, H.PrintDiploma)
eq(coroutine.status(co), "suspended", "the script waits on the page")
hooks.log.pending()
eq(coroutine.status(co), "dead", "and the dismissal resumes it")
end
do
local hooks = newHooks({ noScreens = true })
local vm = newVm(hooks, 4)
local co = run(vm, H.PrintDiploma)
eq(coroutine.status(co), "dead", "no diploma hook is not a hang")
eq(vm.scriptVar, 4, "and wScriptVar survives")
end
-- ---- UnusedMemoryGame ------------------------------------------------------
--
-- DELIBERATELY UNREACHABLE, and not a Crystal gap: `add_special
-- UnusedMemoryGame ; unused` is the row in BOTH carts
-- (pokegold data/events/special_pointers.asm:63,
-- ../pokecrystal/data/events/special_pointers.asm:58) and no script bytecode
-- in either ROM points at it. _MemoryGame (engine/games/memory_game.asm) is a
-- 590-line Game Corner screen with its own board, cursor sprite anim and coin
-- payout, so porting it would be a whole new screen reachable by nobody.
-- UnusedDummySpecial next to it is a bare `ret` and IS ported, because that
-- costs one line; this one is not, and stays a stub with its reason recorded.
check(Specials.STUBS.UnusedMemoryGame ~= nil,
"UnusedMemoryGame stays a stub: no cart script reaches it")
check(type(Specials.STUB_REASONS.UnusedMemoryGame) == "string",
"and it still says why")
-- engine/events/specials.asm:207 is CheckCoinsAndCoinCase then
-- StartGameCornerGame, neither of which writes wScriptVar.
do
local vm = newVm(newHooks(), 9)
Specials.STUBS.UnusedMemoryGame(vm)
eq(vm.scriptVar, 9, "and leaves wScriptVar alone, as the routine does")
end
S.finish()
+401
View File
@@ -0,0 +1,401 @@
-- The six .VarActionTable rows Crystal appends past VAR_SPECIALPHONECALL, and
-- TryQuickSave -- the save Link_SaveGame runs behind the Battle Tower desk.
-- ROM-free:
-- luajit tests/gen2_crystal_vars_test.lua
--
-- ../pokecrystal/constants/script_constants.asm:69-74,
-- ../pokecrystal/engine/overworld/variables.asm:62-67,
-- engine/link/link.asm:2356 and engine/menus/save.asm:63.
--
-- The point of the World half is that these reads and writes land in the SAVE.
-- World.scriptVars is rebuilt empty on every map load (src/world/gen2/World.lua
-- :579), so a var parked there is a var Buena's Blue Card loses the moment the
-- player walks out of the Radio Tower.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or {}
love.graphics = love.graphics or {
getColor = function() return 1, 1, 1, 1 end,
setColor = function() end,
rectangle = function() end,
print = function() end,
printf = function() end,
draw = function() end,
newQuad = function() return {} end,
newImage = function() return nil end,
getShader = function() return nil end,
setShader = function() end,
newShader = function() error("no shaders in this harness") end,
getDimensions = function() return 160, 144 end,
push = function() end, pop = function() end,
translate = function() end, scale = function() end,
circle = function() end, clear = function() end,
}
love.math = love.math or { random = function(a, b) return b and a or 1 end }
love.image = love.image or {}
love.filesystem = love.filesystem or {
load = function() return nil end,
getInfo = function() return nil end,
read = function() return nil end,
}
love.timer = love.timer or { getTime = function() return 0 end }
local S = require("tests.harness").suite("gen2 crystal vars")
local check, eq = S.check, S.eq
require("src.core.Logger").warn = function() end
local Apricorns = require("src.core.gen2.Apricorns")
local Events = require("src.world.gen2.Events")
local Save = require("src.core.gen2.Save")
local SaveSerializer = require("src.core.SaveSerializer")
local Specials = require("src.script.gen2.Specials")
local Vm = require("src.script.gen2.Vm")
local World = require("src.world.gen2.World")
local H = Specials.HANDLERS
-- ../pokecrystal/constants/script_constants.asm:69-74.
local VAR_BT_WIN_STREAK = 0x15
local VAR_KURT_APRICORNS = 0x16
local VAR_CALLERID = 0x17
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
local VAR_KENJI_BREAK = 0x1a
-- ---------------------------------------------------------------- fixtures
local DATA = {
audio = {},
items = {
LEVEL_BALL = { index = 5, name = "LEVEL BALL" },
RED_APRICORN = { index = 78, name = "RED APRICORN" },
BLU_APRICORN = { index = 79, name = "BLU APRICORN" },
},
pokemon = {},
moves = {},
}
local function newSave()
local save = {
version = "crystal",
player = { name = "KRIS", id = 0x1234, money = 0 },
party = {},
inventory = {},
bagOrder = {},
boxes = {},
}
Save.crystalState(save)
Save.battleTowerState(save)
return save
end
local function newWorld(save)
local game = { data = DATA, save = save, stack = nil }
local world = World.new(game)
world.vm = { curPhoneCaller = 0 }
return world, game
end
-- ============================================ the ids, against the cart's own
do
local save = newSave()
local world = newWorld(save)
-- .VarActionTable is walked by index, so a row in the wrong slot reads a
-- different variable. Pin the six by writing a distinct value into each
-- store and reading it back through the id the script byte carries.
Save.battleTowerState(save).streak = 4
save.kurtApricornQuantity = 3
world.vm.curPhoneCaller = 11
Save.crystalState(save).buenaPassword.balance = 17
Save.crystalState(save).buenaPassword.word = 0x52
Save.crystalState(save).kenjiBreak = 5
eq(world:readVar(VAR_BT_WIN_STREAK), 4, "$15 is wNrOfBeatenBattleTowerTrainers")
eq(world:readVar(VAR_KURT_APRICORNS), 3, "$16 is wKurtApricornQuantity")
eq(world:readVar(VAR_CALLERID), 11, "$17 is wCurCaller")
eq(world:readVar(VAR_BLUECARDBALANCE), 17, "$18 is wBlueCardBalance")
eq(world:readVar(VAR_BUENASPASSWORD), 0x52, "$19 is wBuenasPassword")
eq(world:readVar(VAR_KENJI_BREAK), 5, "$1a is wKenjiBreakTimer")
-- NUM_VARS' own guard: nothing above the table answers with a neighbour.
eq(world:readVar(0x1b), 0, "$1b is past the table and reads 0")
end
-- ================================================= the three writable rows
--
-- ../pokecrystal/engine/overworld/variables.asm:21-25: only a RETVAR_ADDR_DE
-- row hands `Script_writevar` the variable's own address. The other three
-- rows write wStringBuffer2, which nothing reads back.
do
local save = newSave()
local world = newWorld(save)
world:writeVar(VAR_BLUECARDBALANCE, 6)
eq(Save.crystalState(save).buenaPassword.balance, 6,
"writevar VAR_BLUECARDBALANCE lands in the save")
eq(world:readVar(VAR_BLUECARDBALANCE), 6, "and reads straight back")
world:writeVar(VAR_BUENASPASSWORD, 0x31)
eq(Save.crystalState(save).buenaPassword.word, 0x31,
"writevar VAR_BUENASPASSWORD lands in the save")
world:writeVar(VAR_CALLERID, 9)
eq(world.vm.curPhoneCaller, 9, "loadvar VAR_CALLERID parks wCurCaller")
eq(world:readVar(VAR_CALLERID), 9, "and the callee scripts read it back")
Save.battleTowerState(save).streak = 2
world:writeVar(VAR_BT_WIN_STREAK, 99)
eq(Save.battleTowerState(save).streak, 2,
"a RETVAR_STRBUF2 row is not writable")
eq(world:readVar(VAR_BT_WIN_STREAK), 2, "so the streak is unchanged")
-- One byte wide, `ld [de], a`.
world:writeVar(VAR_BLUECARDBALANCE, 300)
eq(world:readVar(VAR_BLUECARDBALANCE), 300 % 256, "the store is one byte")
end
-- =========================================== they survive a save and a reload
--
-- The whole bug: scriptVars is rebuilt empty at World.lua:579 and never
-- serialized, so a balance parked there is gone by the next map.
do
local save = newSave()
local world = newWorld(save)
world:writeVar(VAR_BLUECARDBALANCE, 12)
world:writeVar(VAR_BUENASPASSWORD, 0x24)
eq(world.scriptVars[VAR_BLUECARDBALANCE], 12,
"the scratch mirror still holds it in this session")
world.scriptVars = {}
eq(world:readVar(VAR_BLUECARDBALANCE), 12,
"and a map load that clears the mirror does not lose it")
local reloaded = SaveSerializer.decode(SaveSerializer.encode(save))
Save.normalize(reloaded)
local second = newWorld(reloaded)
eq(second:readVar(VAR_BLUECARDBALANCE), 12, "a reload still has the balance")
eq(second:readVar(VAR_BUENASPASSWORD), 0x24, "and the day's password")
end
-- ============================ ../pokecrystal/maps/RadioTower2F.asm:144-146
--
-- readvar VAR_BLUECARDBALANCE / addval 1 / writevar VAR_BLUECARDBALANCE
--
-- run through the VM against a real World, which is the round trip the gate
-- caught reading `blue card balance: 0`.
do
local save = newSave()
local world = newWorld(save)
local vm = Vm.new({}, {}, Events.new(), {
readVar = function(id) return world:readVar(id) end,
writeVar = function(id, value) world:writeVar(id, value) end,
})
local function awardPoint()
vm:start({
{ op = "readvar", var = VAR_BLUECARDBALANCE },
{ op = "addval", args = { 1 } },
{ op = "writevar", var = VAR_BLUECARDBALANCE },
})
local guard = 0
while vm:running() and guard < 64 do
vm:update()
guard = guard + 1
end
check(not vm:running(), "the three rows ran to the end")
end
awardPoint()
eq(world:readVar(VAR_BLUECARDBALANCE), 1, "a correct password is worth 1")
awardPoint()
awardPoint()
eq(Save.crystalState(save).buenaPassword.balance, 3,
"three shows is three points, in the save")
end
-- ======================================= Kurt's quantity, the other dead var
--
-- ../pokecrystal/maps/KurtsHouse.asm:197 is
-- `verbosegiveitemvar LEVEL_BALL, VAR_KURT_APRICORNS`, so a var that reads 0
-- hands over nothing at all.
do
local save = newSave()
save.inventory = { RED_APRICORN = 1 }
local world = newWorld(save)
local quantities = {}
local hooks = {
save = function() return save end,
data = function() return DATA end,
itemName = function(id) return (DATA.items[id] or {}).name or id end,
itemIndex = function(id) return (DATA.items[id] or {}).index end,
setKurtApricornQuantity = function(n)
quantities[#quantities + 1] = n
world:setKurtApricornQuantity(n)
end,
scriptMenu = function(_header, onChoose) onChoose(1) end,
}
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
vm.showTextFn = function() end
vm.co = coroutine.create(function() H.SelectApricornForKurt(vm) end)
local ok, err = coroutine.resume(vm.co)
check(ok, "SelectApricornForKurt ran: " .. tostring(err))
eq(quantities[1], 0, "kurt.asm:24 clears the byte on the way in")
eq(quantities[2], 1, "and kurt.asm:45 records the apricorn it tossed")
eq(world:readVar(VAR_KURT_APRICORNS), 1,
"so verbosegiveitemvar hands over one ball, not none")
eq(save.inventory.RED_APRICORN, nil, "the apricorn left the pack")
-- .GaveKurtApricorns' own setevent, which the map script runs, not this
-- handler: the count must not be conditioned on it.
check(Apricorns.pending(save) == nil, "the event is still the script's to set")
-- The cancel arm leaves the byte at zero, which is the `xor a` at :24.
local cancelled = {}
hooks.setKurtApricornQuantity = function(n) cancelled[#cancelled + 1] = n end
hooks.scriptMenu = function(header, onChoose) onChoose(#header.items) end
local vm2 = Vm.new({}, {}, Events.new(), { specials = hooks })
vm2.showTextFn = function() end
vm2.co = coroutine.create(function() H.SelectApricornForKurt(vm2) end)
coroutine.resume(vm2.co)
eq(#cancelled, 1, "a cancel writes the byte once")
eq(cancelled[1], 0, "and leaves it at zero")
end
-- ========================== SampleKenjiBreakCountdown, the KENJI_BREAK writer
--
-- ../pokecrystal/engine/overworld/time.asm:136: `Random / and %11 / add 3`.
do
eq(Specials.STUB_REASONS.SampleKenjiBreakCountdown, nil,
"SampleKenjiBreakCountdown is no longer a stub")
local save = newSave()
local world = newWorld(save)
local hooks = { setKenjiBreak = function(days) world:setKenjiBreak(days) end }
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
local seen = {}
local priorRandom = Specials.random
for roll = 1, 4 do
Specials.random = function() return roll end
H.SampleKenjiBreakCountdown(vm)
seen[#seen + 1] = world:readVar(VAR_KENJI_BREAK)
end
Specials.random = priorRandom
eq(table.concat(seen, ","), "3,4,5,6", "three to six days, inclusive")
eq(Save.crystalState(save).kenjiBreak, 6, "and the countdown is in the save")
end
-- ================================================================ TryQuickSave
--
-- engine/link/link.asm:2356 -> engine/menus/save.asm:63 Link_SaveGame.
-- ../pokecrystal/maps/BattleTower1F.asm:84-85 is `special TryQuickSave /
-- iffalse Script_Menu_ChallengeExplanationCancel`, so a FALSE here is a challenge that
-- never starts.
do
eq(Specials.STUB_REASONS.TryQuickSave, nil, "TryQuickSave is no longer a stub")
eq(Specials.HANDLER_SOURCE.TryQuickSave, "Specials.lua",
"and Specials.lua owns the handler")
end
local function runQuickSave(opts)
local save = newSave()
local log = { writes = 0, sfx = {} }
local hooks = {
save = function() return save end,
data = function() return DATA end,
saveFileState = function() return opts.exists, opts.sameId end,
writeSave = function()
log.writes = log.writes + 1
return opts.writeOk ~= false
end,
playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end,
}
local vm = Vm.new({}, {}, Events.new(), { specials = hooks })
vm.showTextFn = function() end
local pages, holds, asked = {}, {}, 0
local co = coroutine.create(function() H.TryQuickSave(vm) end)
vm.co = co
local send
while true do
local ok, req = coroutine.resume(co, send)
if not ok then error(req, 0) end
if coroutine.status(co) == "dead" then break end
send = nil
if type(req) == "table" and req.kind == "text" then
pages[#pages + 1] = req.text
holds[#holds + 1] = req.hold or 0
elseif type(req) == "table" and req.kind == "yesorno" then
asked = asked + 1
send = opts.answers and opts.answers[asked]
else
error("TryQuickSave parked on " .. tostring(req and req.kind), 0)
end
end
log.pages, log.holds, log.asked = pages, holds, asked
log.scriptVar = vm.scriptVar
return log
end
-- .yoursavefile: an existing file with this player's ID.
do
local log = runQuickSave({ exists = true, sameId = true, answers = { true } })
eq(log.asked, 1, "AskOverwriteSaveFile asks once")
check(log.pages[1]:find("already a", 1, true) ~= nil,
"AlreadyASaveFileText is the question: " .. tostring(log.pages[1]))
eq(log.writes, 1, "_SaveGameData ran")
eq(log.scriptVar, 1, "and wScriptVar is TRUE")
check(log.pages[2]:find("SAVING", 1, true) ~= nil, "SAVING page printed")
eq(log.holds[2], 16 + 32, "held for save.asm:247 + :251")
check(log.pages[3]:find("KRIS saved", 1, true) ~= nil,
"SavedTheGameText names the player: " .. tostring(log.pages[3]))
eq(log.holds[3], 30 + 30, "held for save.asm:269 + link.asm:2367")
eq(log.sfx[1], "Sfx_Save", "SFX_SAVE rang")
end
-- .refused: `jr nz, .refused / scf`, which TryQuickSave turns into FALSE.
do
local log = runQuickSave({ exists = true, sameId = true, answers = { false } })
eq(log.asked, 1, "the question was asked")
eq(log.writes, 0, "NO writes nothing")
eq(log.scriptVar, 0, "and wScriptVar is FALSE")
eq(#log.pages, 1, "no SAVING page follows a refusal")
end
-- The other ID: AnotherSaveFileText, and .erase rather than .ok.
do
local log = runQuickSave({ exists = true, sameId = false, answers = { true } })
check(log.pages[1]:find("another", 1, true) ~= nil,
"AnotherSaveFileText: " .. tostring(log.pages[1]))
eq(log.writes, 1, "and it still saves")
eq(log.scriptVar, 1, "TRUE")
end
-- `ld a, [wSaveFileExists] / and a / jr z, .erase`: no file, no question.
do
local log = runQuickSave({ exists = false, sameId = false })
eq(log.asked, 0, "a first save is not asked to overwrite anything")
eq(log.writes, 1, "it just writes")
eq(log.scriptVar, 1, "TRUE")
end
-- The one refusal the cart has no equivalent for: a mod vetoing save.write.
do
local log = runQuickSave({ exists = false, writeOk = false })
eq(log.writes, 1, "the write was attempted")
eq(log.scriptVar, 0, "a refused write is the same FALSE as a refused prompt")
end
-- ====================================== the World half of the two save hooks
do
local save = newSave()
local world, game = newWorld(save)
local wrote = 0
game.writeSave = function() wrote = wrote + 1 return true end
check(world:writeSave() == true, "World:writeSave goes through Game2:writeSave")
eq(wrote, 1, "exactly once")
game.writeSave = function() return false end
check(world:writeSave() == false, "and a vetoed write reports false")
game.writeSave = nil
check(world:writeSave() == false, "with no game there is nothing to write")
local hooks = world:specialHooks()
check(type(hooks.writeSave) == "function", "the writeSave hook is threaded")
check(type(hooks.saveFileState) == "function", "so is saveFileState")
end
S.finish()
+35 -12
View File
@@ -158,10 +158,17 @@ else
if path:find("font%.png$") then
images[path].getDimensions = function() return 128, 64 end
end
-- One row per frame style, all eight (pokegold/gfx/font.asm:10).
if path:find("frames%.png$") then
images[path].getDimensions = function() return 48, 64 end
end
return images[path]
end
Font.load({ font = fontDef })
require("src.render.Assets").image = realImage
-- currentFrame is a Font module-local that outlives a suite, and this file
-- is also dofile'd into tests/run_tests.lua after screens that cycle it.
Font.setFrame(1)
-- Which sheet a code lands on, by the image drawCode reaches for.
local drawn
@@ -186,11 +193,18 @@ else
"the swap runs to the last of the 25 tiles it loads")
-- `jr LoadFrame` at the end of _LoadFontsBattleExtra: $79-$7e are the
-- textbox frame on a battle-sheet screen too, so a box drawn there is the
-- same box as everywhere else.
for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr",
[0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do
eq(sheetOf(code), "font_extra.png",
("LoadFrame keeps the %s border glyph off the battle sheet"):format(name))
-- same box as everywhere else. LoadFrame reads the `Frames` label, not
-- FontExtra (pokegold/engine/gfx/load_font.asm:29-39).
-- A cache built before the extractor split Frames out has no imageFrames
-- row, and its $79-$7e fall back to the extra sheet's baked-in frame 1.
if not fontDef.imageFrames then
check(true, "cache predates the frames sheet (SKIP the border routing)")
else
for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr",
[0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do
eq(sheetOf(code), "frames.png",
("LoadFrame keeps the %s border glyph off the battle sheet"):format(name))
end
end
Font.useBattleExtra(false)
love.graphics.draw = realDraw
@@ -369,11 +383,16 @@ stopRecording()
local dexLine = findPrint("№.")
check(dexLine ~= nil, "the roster line places the No ligature")
check(dexLine and dexLine.battleExtra == true,
"with the battle sheet in the $60 slot, where that glyph lives")
local idLine = findPrint("<ID>№/")
check(idLine ~= nil and idLine.battleExtra == true,
"and so does the trainer id line")
-- Which sheet a glyph resolves against needs the cache's real font rows.
if not fontDef then
check(true, "no Gold cache (SKIP the battle-sheet routing)")
else
check(dexLine and dexLine.battleExtra == true,
"with the battle sheet in the $60 slot, where that glyph lives")
check(idLine ~= nil and idLine.battleExtra == true,
"and so does the trainer id line")
end
eq(Font.battleExtraActive(), false,
"the sheet the caller had is put back when the screen is done")
@@ -392,10 +411,14 @@ photo:draw()
stopRecording()
local photoDex = findPrint("№.")
check(photoDex ~= nil and photoDex.battleExtra == true,
"the photo card's No ligature resolves against the battle sheet")
local photoId = findPrint("<ID>№")
check(photoId ~= nil and photoId.battleExtra == true, "and its <ID> does too")
if not fontDef then
check(true, "no Gold cache (SKIP the photo card's battle-sheet routing)")
else
check(photoDex ~= nil and photoDex.battleExtra == true,
"the photo card's No ligature resolves against the battle sheet")
check(photoId ~= nil and photoId.battleExtra == true, "and its <ID> does too")
end
eq(Font.battleExtraActive(), false, "and the card puts the sheet back")
S.finish()
+6 -3
View File
@@ -357,8 +357,9 @@ local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
check("twenty-three rows", #OptionsMenu.ROWS, 23)
-- video mode, screen position, the mobile-gated touch three (buildRows),
-- MAX FPS and CANCEL.
check("twenty-four rows", #OptionsMenu.ROWS, 24)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
@@ -965,7 +966,9 @@ check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
"TILT")
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
"VIDEO MODE")
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
check("and SCREEN POS follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
"SCREEN POS")
check("and TOUCH PAD follows that", OptionsMenu.ROWS[gbcfxIndex + 3].label,
"TOUCH PAD")
local videoRow = select(2, rowNamed("VIDEO MODE"))
+183
View File
@@ -0,0 +1,183 @@
-- TruncateHL_BC (../pokegold/engine/battle/effect_commands.asm:2625,
-- ../pokecrystal/engine/battle/effect_commands.asm:2614).
--
-- luajit tests/gen2_reflect_overflow_test.lua
--
-- ROM-free. The cart hands BattleCommand_DamageCalc one-byte stats, so a
-- 16-bit attack/defence pair is shifted right twice -- both together, so the
-- ratio survives -- and the low byte taken. Gold runs that pass exactly once
-- and keeps whatever the low byte then holds, so Reflect or Light Screen on a
-- 512+ defence pushes the doubled value to 1024 and the truncated byte wraps
-- to 0; DamageCalc's minimum-defence check turns that into 1 and the hit caps.
-- Crystal repeats the pass until both stats fit, which is the fix CT-10 names
-- reflectOverflow. Crystal kept the old arithmetic under LINK_COLOSSEUM; the
-- Gen 2 battle engine here has no link mode, so only the single-player halves
-- are pinned.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 reflect overflow")
local check, eq = S.check, S.eq
local GameVersion = require("src.core.GameVersion")
local Damage = require("src.battle.gen2.Damage")
local restore = GameVersion.get()
-- One NORMAL physical hit with no type table, no STAB and the damage roll
-- pinned at 100%, so the only moving part is the truncated defence.
local function hit(version, defense, screen)
GameVersion.set(version)
return (Damage.calc({
level = 50, power = 100, moveType = "NORMAL",
attacker = { attack = 200 },
defender = { defense = defense },
screen = screen, variation = 100,
}))
end
local function truncate(version, attack, defense)
GameVersion.set(version)
return Damage.truncateStats(attack, defense,
GameVersion.fixes().reflectOverflow == true)
end
-- --------------------------------------------------------- the gate itself
do
eq(GameVersion.fixes("gold").reflectOverflow, nil,
"Gold keeps the cart's single truncation pass")
eq(GameVersion.fixes("silver").reflectOverflow, nil,
"Silver keeps it too")
eq(GameVersion.fixes("crystal").reflectOverflow, true,
"Crystal is the version that loops")
end
-- ------------------------------------------------- below the truncate line
do
-- Both stats already fit in a byte, so TruncateHL_BC returns at once and
-- every ordinary battle is untouched by either arm.
local a, d = truncate("gold", 200, 100)
eq(a, 200, "an 8-bit attack passes through")
eq(d, 100, "an 8-bit defence passes through")
eq(hit("gold", 100, false), 90, "Gold, defence 100, no screen")
eq(hit("crystal", 100, false), 90, "Crystal agrees")
eq(hit("gold", 100, true), 46, "Gold, defence 100 doubled to 200")
eq(hit("crystal", 100, true), 46, "Crystal agrees")
end
-- ----------------------------------------- one pass, which both games share
do
-- 511 doubled is 1022; one >>2 gives 255, which fits, so Crystal's loop
-- never runs a second time and the two versions still agree. This is the
-- largest defence Reflect can double without the wrap.
local ga, gd = truncate("gold", 200, 511 * 2)
local ca, cd = truncate("crystal", 200, 511 * 2)
eq(gd, 255, "1022 >> 2 is 255, the last value that fits")
eq(ga, 50, "the attack is shifted with it, 200 >> 2")
eq(cd, 255, "Crystal's loop exits on the same pass")
eq(ca, 50, "and leaves the attack alone as well")
eq(hit("gold", 511, true), 10, "Gold, defence 511 doubled")
eq(hit("crystal", 511, true), 10, "Crystal, defence 511 doubled")
end
do
-- A big defence with no screen cannot reach 1024 on its own (stats cap at
-- 999), so the gate must not move any unscreened hit.
for _, defense in ipairs({ 256, 400, 512, 700, 999 }) do
eq(hit("gold", defense, false), hit("crystal", defense, false),
("defence %d without a screen is version-independent"):format(defense))
end
eq(hit("gold", 999, false), 10, "defence 999 unscreened, both versions")
end
-- ------------------------------------------------------------- the wrap
do
-- 512 doubled is exactly 1024. Gold: 1024 >> 2 = 256, low byte 0, and
-- DamageCalc's `ld c, 1` turns that into a defence of 1. Crystal: a second
-- pass takes 256 -> 64 and the attack 50 -> 12.
local ga, gd = truncate("gold", 200, 1024)
eq(gd, 0, "Gold truncates 1024 to a defence byte of 0")
eq(ga, 50, "Gold's attack byte after the single pass")
local ca, cd = truncate("crystal", 200, 1024)
eq(cd, 64, "Crystal shifts again, 256 >> 2")
eq(ca, 12, "and the attack with it, 50 >> 2")
-- 22 * 100 * 50 / 1 / 50 = 2200, capped to 997 + 2.
eq(hit("gold", 512, true), 999, "Gold's Reflect caps the hit at 999")
-- 22 * 100 * 12 / 64 / 50 = 8, + 2.
eq(hit("crystal", 512, true), 10, "Crystal's Reflect keeps the hit at 10")
check(hit("gold", 512, false) == 19,
"and the same defence unscreened is 19 on both")
check(hit("gold", 512, true) > hit("gold", 512, false),
"Gold's Reflect makes the defender take MORE damage")
check(hit("crystal", 512, true) < hit("crystal", 512, false),
"Crystal's Reflect halves it, as it should")
end
do
-- The worst case the cart can build: a 999 defence doubled to 1998.
-- Gold: 1998 >> 2 = 499, low byte 243. Crystal: 499 >> 2 = 124, attack 12.
local ga, gd = truncate("gold", 200, 999 * 2)
eq(gd, 243, "Gold wraps 499 to 243")
eq(ga, 50, "Gold's attack byte")
local ca, cd = truncate("crystal", 200, 999 * 2)
eq(cd, 124, "Crystal shifts 499 to 124")
eq(ca, 12, "and the attack to 12")
eq(hit("gold", 999, true), 11, "Gold: Reflect on a 999 defence deals 11")
eq(hit("crystal", 999, true), 6, "Crystal: the same hit deals 6")
eq(hit("gold", 999, false), 10, "unscreened it is 10, so the screen HURTS")
end
-- ------------------------------------------------ the minimum-1 arms
do
-- `ld a, c / or b / jr nz / inc c` and its hl twin: a stat that shifts down
-- to zero is forced back to 1 before the next pass, on both versions.
local ga, gd = truncate("gold", 3, 2000)
eq(ga, 1, "Gold: an attack of 3 shifts to 0 and is forced to 1")
eq(gd, 244, "Gold: 2000 >> 2 = 500, low byte 244")
local ca, cd = truncate("crystal", 3, 2000)
eq(ca, 1, "Crystal: the forced 1 shifts to 0 and is forced again")
eq(cd, 125, "Crystal: 500 >> 2 = 125")
end
-- ------------------------------------------------------------- the override
do
-- The gate is readable per call, which is where a LINK_COLOSSEUM carve-out
-- would hang if the Gen 2 engine ever grows one.
GameVersion.set("crystal")
local bugged = Damage.calc({
level = 50, power = 100, moveType = "NORMAL",
attacker = { attack = 200 }, defender = { defense = 512 },
screen = true, variation = 100, reflectOverflowFixed = false,
})
eq(bugged, 999, "Crystal forced onto the bugged arm matches Gold")
GameVersion.set("gold")
local fixed = Damage.calc({
level = 50, power = 100, moveType = "NORMAL",
attacker = { attack = 200 }, defender = { defense = 512 },
screen = true, variation = 100, reflectOverflowFixed = true,
})
eq(fixed, 10, "Gold forced onto the fixed arm matches Crystal")
end
-- ------------------------------------------------------- the 999 stat cap
do
-- ApplyStatLevelMultiplier caps at MAX_STAT_VALUE before anything doubles
-- it (../pokecrystal/engine/battle/core.asm:6739), which is what bounds the
-- doubled defence at 1998 rather than letting +6 stages run past it.
eq(Damage.applyStage(999, 6), 999, "a +6 stage cannot pass 999")
eq(Damage.applyStage(250, 6), 999, "250 x4 is 1000, capped to 999")
eq(Damage.applyStage(249, 6), 996, "249 x4 stays below the cap")
eq(Damage.applyStage(1, -6), 1, "and a stat never reaches 0")
end
GameVersion.set(restore)
S.finish()
+16 -2
View File
@@ -33,7 +33,7 @@ end
-- A label the list names but the symbol table cannot place would fail the
-- import at the Dialogue stage rather than at generation time, so the pairing
-- is asserted here instead.
for _, edition in ipairs({ "gold", "silver" }) do
for _, edition in ipairs({ "gold", "silver", "crystal" }) do
local data = manifest("tools/rom_manifest_" .. edition .. ".json")
local labels = (data.text or {}).labels or {}
check((data.text or {}).labels ~= nil,
@@ -64,7 +64,7 @@ for _, edition in ipairs({ "gold", "silver" }) do
end
end
-- Both editions describe the same strings; only the addresses move.
-- Gold and Silver describe the same strings; only the addresses move.
do
local gold = (manifest("tools/rom_manifest_gold.json").text or {}).labels or {}
local silver =
@@ -75,6 +75,20 @@ do
if silver[index] ~= label then mismatch = label; break end
end
eq(mismatch, nil, "and the same labels in the same order")
local crystal =
(manifest("tools/rom_manifest_crystal.json").text or {}).labels or {}
check(#crystal > #gold,
("Crystal names more labels than Gold (%d vs %d)"):format(#crystal, #gold))
local named = {}
for _, label in ipairs(gold) do named[label] = true end
local extra = 0
for _, label in ipairs(crystal) do
if not named[label] then extra = extra + 1 end
end
check(extra > 0,
("and %d of them are Crystal-only, so it is not a Gold alias")
:format(extra))
end
-- ---- the slots RomText fills ----------------------------------------------
+19 -1
View File
@@ -117,16 +117,34 @@ check("a Gen 2 version already on the table is kept",
Save.normalize({ version = "silver", player = {} }).version, "silver")
check("and a Gen 1 one is replaced by the running edition",
Save.normalize({ version = "red", player = {} }).version, "gold")
check("and Crystal is a Gen 2 version, so it keeps its own",
Save.normalize({ version = "crystal", player = {} }).version, "crystal")
-- "nonesuch" is the deliberate never-a-game token: "gold" stood here until
-- Gold shipped, "crystal" until Crystal did. This one never becomes a game.
check("as is a version this engine does not have",
Save.normalize({ version = "crystal", player = {} }).version, "gold")
Save.normalize({ version = "nonesuch", player = {} }).version, "gold")
check("party exists", type(sparse.party), "table")
check("inventory exists", type(sparse.inventory), "table")
check("pokedex seen exists", type(sparse.pokedex.seen), "table")
check("phone book exists", type(sparse.phoneContacts), "table")
check("play time exists", type(sparse.playTime), "table")
check("name defaulted", sparse.player.name, "GOLD")
-- wPlayerGender is zeroed by _ResetWRAM and Gold never writes it, so a
-- normalized save has to come back with the field present rather than nil.
check("gender defaulted", sparse.player.gender, "male")
check("and a recorded gender is kept",
Save.normalize({ player = { gender = "female" } }).player.gender, "female")
check("normalize rejects a non-table", Save.normalize("nope"), nil)
-- The blank-name fallback is table driven so Crystal can be a row rather than
-- a third arm of a two-way test (data/player_names.asm).
check("Gold's PlayerNameArray row", Save.defaultPlayerName("gold"), "GOLD")
check("Silver's", Save.defaultPlayerName("silver"), "SILVER")
check("Crystal's MalePlayerNameArray row",
Save.defaultPlayerName("crystal"), "CHRIS")
check("and an unknown edition falls back to Gold's",
Save.defaultPlayerName("nonesuch"), "GOLD")
-- Money and coins are clamped to their caps, and a negative is floored at 0.
local rich = Save.normalize({ player = { money = 9999999, coins = 99999 } })
check("money capped", rich.player.money, Save.MAX_MONEY)
+233
View File
@@ -0,0 +1,233 @@
-- The four Ruins of Alph secret chambers:
-- ../pokecrystal/engine/events/unown_walls.asm:1 HoOhChamber, :13
-- OmanyteChamber, :54 SpecialAerodactylChamber, :81 SpecialKabutoChamber.
-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_chambers_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 unown chambers")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Events = require("src.world.gen2.Events")
local Specials = require("src.script.gen2.Specials")
local UnownWords = require("src.world.gen2.UnownWords")
local function loadTable(path)
local chunk = loadfile(path)
return chunk and chunk() or nil
end
local function fakeVm(opts)
opts = opts or {}
local pack = opts.pack or {}
return {
scriptVar = 0,
events = opts.events or Events.new(),
specials = {
party = function() return opts.party or {} end,
itemIndex = function(id) return id == "WATER_STONE" and 24 or nil end,
hasItem = function(index) return pack[index] == true end,
},
}
end
local WALLS = {
{ "HO_OH", 806 }, { "KABUTO", 807 },
{ "OMANYTE", 808 }, { "AERODACTYL", 809 },
}
-- ../pokecrystal/constants/event_flags.asm:486-489
do
for _, row in ipairs(WALLS) do
eq(UnownWords.WALL_OPENED[row[1]], row[2],
"EVENT_WALL_OPENED_IN_" .. row[1] .. "_CHAMBER is flag " .. row[2])
end
local events = Events.new()
check(not UnownWords.wallOpened(events, "OMANYTE"), "a fresh save has no wall open")
check(UnownWords.openWall(events, "OMANYTE"), "the first SET_FLAG reports the change")
check(UnownWords.wallOpened(events, "OMANYTE"), "and CHECK_FLAG reads it back")
check(not UnownWords.openWall(events, "OMANYTE"), "a second one is a no-op")
check(not UnownWords.wallOpened(events, "KABUTO"),
"and it did not open any of the other three")
check(not UnownWords.openWall(nil, "OMANYTE"), "no bitfield, no write")
check(not UnownWords.openWall(Events.new(), "BETA"),
"and a chamber the cart does not have has no flag to set")
end
-- ../pokecrystal/engine/events/unown_walls.asm:2-5 HoOhChamber
do
check(UnownWords.leadIsHoOh({ { species = "HO_OH" } }),
"Ho-Oh in the first slot is what the routine asks for")
check(not UnownWords.leadIsHoOh({ { species = "LUGIA" }, { species = "HO_OH" } }),
"Ho-Oh in the second slot is not: it reads wPartySpecies[0] only")
check(not UnownWords.leadIsHoOh({ { species = "HO_OH", isEgg = true } }),
"an egg holds EGG in wPartySpecies, not the hatchling's species")
check(not UnownWords.leadIsHoOh({}), "and an empty party is not Ho-Oh")
local vm = fakeVm({ party = { { species = "HO_OH" } } })
Specials.ALL.HoOhChamber(vm)
check(UnownWords.wallOpened(vm.events, "HO_OH"), "the handler opens the wall")
eq(vm.scriptVar, 0, "and leaves wScriptVar alone: the scene's own checkevent reads the flag")
vm = fakeVm({ party = { { species = "SUICUNE" } } })
Specials.ALL.HoOhChamber(vm)
check(not UnownWords.wallOpened(vm.events, "HO_OH"),
"any other lead leaves it shut")
vm = fakeVm({ party = { { species = "HO_OH" } } })
Specials.ALL.HoOhChamber(vm)
check(not UnownWords.wallOpened(vm.events, "OMANYTE"),
"and it never touches the Omanyte bit")
end
-- ../pokecrystal/engine/events/unown_walls.asm:28-43 OmanyteChamber
do
eq(UnownWords.waterStoneSlot({ { item = "WATER_STONE" } }), 1,
"one held Water Stone is found")
eq(UnownWords.waterStoneSlot(
{ { item = "WATER_STONE" }, {}, { item = "WATER_STONE" } }), 3,
"and with two, the backwards walk stops at the LAST slot")
check(UnownWords.waterStoneSlot({ { item = "FIRE_STONE" }, {} }) == nil,
"no stone, no slot")
check(UnownWords.waterStoneSlot(nil) == nil, "and no party is no slot")
local vm = fakeVm({ pack = { [24] = true } })
Specials.ALL.OmanyteChamber(vm)
check(UnownWords.wallOpened(vm.events, "OMANYTE"),
"CheckItem on the pack alone opens it")
vm = fakeVm({ party = { {}, { item = "WATER_STONE" } } })
Specials.ALL.OmanyteChamber(vm)
check(UnownWords.wallOpened(vm.events, "OMANYTE"),
"so does a Water Stone held by a party mon")
vm = fakeVm({ party = { { item = "FIRE_STONE" } } })
Specials.ALL.OmanyteChamber(vm)
check(not UnownWords.wallOpened(vm.events, "OMANYTE"),
"neither in the pack nor held leaves it shut")
vm = fakeVm({})
Specials.ALL.OmanyteChamber(vm)
check(not UnownWords.wallOpened(vm.events, "OMANYTE"),
"and an empty party with an empty pack does nothing")
-- ../pokecrystal/engine/events/unown_walls.asm:14-20
local seen = 0
vm = fakeVm({})
vm.specials.party = function() seen = seen + 1 return {} end
UnownWords.openWall(vm.events, "OMANYTE")
Specials.ALL.OmanyteChamber(vm)
eq(seen, 0, "an already-open wall returns before the party is walked")
end
-- ../pokecrystal/engine/events/unown_walls.asm:54, :81, both reached from a
-- field move rather than from a `special`.
do
local events = Events.new()
check(not UnownWords.aerodactylChamber(events, "RUINS_OF_ALPH_KABUTO_CHAMBER"),
"Flash in the wrong chamber returns no carry")
check(not UnownWords.wallOpened(events, "AERODACTYL"), "and opens nothing")
check(not UnownWords.aerodactylChamber(events, "DARK_CAVE_VIOLET_ENTRANCE"),
"nor does Flash in an ordinary dark cave")
check(UnownWords.aerodactylChamber(events, "RUINS_OF_ALPH_AERODACTYL_CHAMBER"),
"in its own chamber it returns the carry FlashFunction jumps on")
check(UnownWords.wallOpened(events, "AERODACTYL"), "and sets the bit")
events = Events.new()
check(not UnownWords.kabutoChamber(events, "RUINS_OF_ALPH_OMANYTE_CHAMBER"),
"an escape rope elsewhere does nothing")
check(not UnownWords.wallOpened(events, "KABUTO"), "and leaves the bit clear")
check(UnownWords.kabutoChamber(events, "RUINS_OF_ALPH_KABUTO_CHAMBER"),
"in the Kabuto chamber it fires")
check(UnownWords.wallOpened(events, "KABUTO"), "and sets its own bit")
check(not UnownWords.wallOpened(events, "AERODACTYL"),
"with the Aerodactyl bit untouched")
end
-- data/events/special_pointers.asm:148 OmanyteChamber, :157 HoOhChamber
do
for _, name in ipairs({ "OmanyteChamber", "HoOhChamber" }) do
check(type(Specials.ALL[name]) == "function", name .. " is a handler")
check(Specials.STUBS[name] == nil, name .. " is no longer a stub")
eq(Specials.HANDLER_SOURCE[name], "specials/crystal_story.lua",
"owned by this unit's module")
check(Specials.SUPERSEDED_STUBS[name] ~= nil,
"and its stub reason moved to SUPERSEDED_STUBS")
end
end
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
cache = (os.getenv("HOME") or "")
.. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local maps = loadTable(cache .. "/data/generated/maps.lua")
local scripts = loadTable(cache .. "/data/generated/scripts.lua")
local consts = loadTable(cache .. "/data/generated/constants.lua")
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:22-24, the
-- MAPCALLBACK_TILES callback that re-derives the flag numbers from the cart.
local specialId = {}
for id, name in pairs((consts or {}).specialOrder or {}) do
specialId[name] = id - 1
end
if not (maps and scripts and consts) then
check(true, "no cache: the chamber scripts are not walked (SKIP)")
elseif not specialId.OmanyteChamber then
-- data/events/special_pointers.asm:124 `; Crystal only`
check(true, "cache is not a Crystal one (SKIP)")
else
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10 and
-- RuinsOfAlphHoOhChamber.asm:10; the other two chambers have no `special`.
local EXPECTED = {
HO_OH = "HoOhChamber",
OMANYTE = "OmanyteChamber",
KABUTO = false,
AERODACTYL = false,
}
for chamber, wantSpecial in pairs(EXPECTED) do
local mapId = UnownWords.CHAMBER_MAPS[chamber]
local def = maps[mapId]
if not def then
check(false, mapId .. " is in the cache")
else
local callback = (def.callbacks or {})[1]
local rows = callback and scripts[callback.scriptKey]
local first = rows and rows[1]
eq(callback and callback.callback, "MAPCALLBACK_TILES",
mapId .. " has its hidden-doors callback")
eq(first and first.op, "checkevent", "which opens on a checkevent")
eq(first and first.event, UnownWords.WALL_OPENED[chamber],
"of the very flag " .. chamber .. "'s routine writes")
local scene
for _, entry in pairs(def.sceneScripts or {}) do
if (entry.sceneId or 0) == 0 then scene = entry end
end
local sceneRows = scene and scripts[scene.scriptKey] or {}
local got
for _, row in ipairs(sceneRows) do
if row.op == "special" then got = row.id end
end
if wantSpecial then
eq(got, specialId[wantSpecial],
mapId .. "'s check-wall scene runs " .. wantSpecial)
else
check(got == nil,
mapId .. "'s check-wall scene runs no special: its trigger is a field move")
end
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:11
local read
for _, row in ipairs(sceneRows) do
if row.op == "checkevent" then read = row.event end
end
eq(read, UnownWords.WALL_OPENED[chamber],
"and it reads the same flag straight afterwards")
end
end
end
S.finish()
+3 -2
View File
@@ -285,8 +285,9 @@ end
check(Specials.HANDLERS.UnownPrinter ~= nil,
"UnownPrinter has a handler: the viewer half needs no printer")
eq(Specials.STUBS.UnownPrinter, nil, "and is not also stubbed")
check(Specials.STUBS.PrintDiploma ~= nil,
"PrintDiploma, which is nothing but a print, still is")
check(Specials.HANDLERS.PrintDiploma ~= nil,
"PrintDiploma took the same route: the diploma page is drawn on the "
.. "cartridge and only the send wanted a printer")
-- The hook a Specials handler reaches for lives in World:specialHooks, NOT in
-- the table handed to Vm.new -- a hook registered in the wrong one is
+310
View File
@@ -0,0 +1,310 @@
-- The Ruins of Alph wall words: engine/events/unown_walls.asm:102
-- DisplayUnownWords and data/events/unown_walls.asm:7 UnownWalls.
-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_words_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 unown words")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Json = require("src.link.Json")
local UnownWords = require("src.world.gen2.UnownWords")
local function readFile(path, mode)
local f = io.open(path, mode or "rb")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
local function pngSize(body)
local function be32(s, i)
local a, b, c, d = s:byte(i, i + 3)
return ((a * 256 + b) * 256 + c) * 256 + d
end
return be32(body, 17), be32(body, 21)
end
local function loadTable(path)
local chunk = loadfile(path)
return chunk and chunk() or nil
end
-- engine/events/unown_walls.asm:201 .ConvertChar
do
local tl, tr, bl, br = UnownWords.square(0x00) -- 'A'
eq(tl, 0x80, "A's top-left is bank 1 tile $00")
eq(tr, 0x81, "top-right is the next tile")
eq(bl, 0x90, "bottom-left is a 16-tile row down")
eq(br, 0x91, "and bottom-right beside it")
eq((UnownWords.square(0x0e)), 0x8e, "H is bank 1 tile $0e")
eq((UnownWords.square(0x20)), 0xa0, "I steps a whole row: bank 1 tile $20")
eq((UnownWords.square(0x4e)), 0xce, "X is the last computed letter")
local y1, y2, y3, y4 = UnownWords.square(0x60)
eq(y1 * 0x1000000 + y2 * 0x10000 + y3 * 0x100 + y4,
0x5b5c4d5d, "Y is .YChar's four bank 0 tiles")
local z1, z2, z3, z4 = UnownWords.square(0x62)
eq(z1 * 0x1000000 + z2 * 0x10000 + z3 * 0x100 + z4,
0x4e4f5e5f, "Z is .ZChar's")
local d1, d2, d3, d4 = UnownWords.square(0x64)
eq(d1 * 0x1000000 + d2 * 0x10000 + d3 * 0x100 + d4,
0x02030302, "and the dash is .DashChar's")
end
-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls, the n = 6 row.
do
local escape = { x1 = 3, y1 = 4, x2 = 16, y2 = 9,
chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } }
local bx, by, bw, bh = UnownWords.boxRect(escape)
eq(bx, 3, "the box starts at column 3")
eq(by, 4, "row 4")
eq(bw, 14, "and spans the coordinate pair inclusive: 14 columns")
eq(bh, 6, "by 6 rows")
local ox, oy = UnownWords.origin(escape)
eq(ox, 4, "the first square is one column inside the frame")
eq(oy, 6, "and two rows down")
local squares = UnownWords.layout(escape)
eq(#squares, 6, "ESCAPE is six squares")
eq(squares[1].tx, 4, "the E starts at column 4")
eq(squares[2].tx, 6, "and every letter is two tiles wide")
eq(squares[6].tx, 14, "so the last one ends on the frame's inner edge")
eq(squares[1].ty, 6, "all of them on the same row")
eq(squares[4].tl, 0x80, "the A in ESCAPE is bank 1 tile $00")
end
-- constants/charmap.asm:424 the `unown` charmap.
do
local body = readFile("tools/rom_manifest_crystal.json", "r")
if not body then
check(true, "no tools/rom_manifest_crystal.json (SKIP)")
else
local manifest = Json.decode(body)
local map = manifest.unownCharmap
if not map then
check(false, "the Crystal manifest carries unownCharmap")
else
eq(map["0"], "A", "$00 is A")
eq(map["2"], "B", "$02 is B, two tiles along")
eq(map["14"], "H", "$0e is H, the last of the first row")
eq(map["32"], "I", "$20 is I: the row step is $10 every eight letters")
eq(map["78"], "X", "$4e is X")
eq(map["96"], "Y", "$60 is Y")
eq(map["98"], "Z", "$62 is Z")
eq(map["100"], "-", "$64 is the dash")
eq(map["255"], "@", "and $ff terminates a word")
local count = 0
for _ in pairs(map) do count = count + 1 end
eq(count, 28, "27 printable characters plus the terminator")
end
-- constants/charmap.asm:5 -- $00 is <NULL> in the main charmap.
local main = manifest.charmap or {}
check(main["0"] ~= "A", "the main charmap did not absorb the Unown rows")
eq(manifest.symbols.UnownWalls[1], 0x22,
"UnownWalls is in the bank pokecrystal.sym says")
eq(manifest.symbols.UnownWalls[2], 0x6ebc, "and at its address")
eq(manifest.symbols.MenuHeaders_UnownWalls[2], 0x6ed5,
"with the menu headers immediately after it")
end
end
do
for _, edition in ipairs({ "gold", "silver" }) do
local body = readFile("tools/rom_manifest_" .. edition .. ".json", "r")
if not body then
check(true, "no " .. edition .. " manifest (SKIP)")
else
local manifest = Json.decode(body)
check(manifest.unownCharmap == nil,
edition .. " has no Unown charmap: the block is Crystal only")
check(manifest.symbols.UnownWalls == nil,
edition .. " has no UnownWalls either")
end
end
end
-- constants/charmap.asm:423,433 the two `pushc` blocks the main parser skips.
do
local source = readFile("tools/make_gold_manifest.py", "r")
if not source then
check(true, "no tools/make_gold_manifest.py (SKIP)")
else
check(source:find('if re.match(r"(pushc|newcharmap)\\b", stripped):',
1, true) ~= nil, "the main charmap parser still stops at newcharmap")
end
local crystal = readFile("tools/make_crystal_manifest.py", "r")
if not crystal then
check(true, "no tools/make_crystal_manifest.py (SKIP)")
else
check(crystal:find('data["unownCharmap"] = unown_charmap(', 1, true) ~= nil,
"and the Unown one is generated beside it, under its own key")
check(crystal:find("PRINTABLE_UNOWN", 1, true) == nil,
"with the letter set read out of charmap.asm, not copied here")
end
end
-- home/map.asm:1357-1370 LoadTilesetGFX's two CopyBytes.
do
local source = assert(readFile("src/import/RomExtractorGen2.lua", "r"))
check(source:find("local function crystalTilesetSheet(pixels)", 1, true)
~= nil, "the Crystal sheet lays both VRAM banks out at their tile ids")
check(source:find("if twoBank then pixels = crystalTilesetSheet(pixels) end",
1, true) ~= nil, "and extractTilesets uses it")
check(source:find("local CRYSTAL_PAL_MAP_BYTES = 112", 1, true) ~= nil,
"the PalMap read covers the bank 1 rows too")
check(source:find("out.unownWalls = walls", 1, true) ~= nil,
"and readEventTables emits the wall words")
end
-- gfx/tilesets/ruins_of_alph.png, whose bottom half is the Unown alphabet.
do
local png = readFile(
"../pokecrystal/gfx/tilesets/ruins_of_alph.png")
if not png then
check(true, "no ../pokecrystal: the tileset's shape is not pinned (SKIP)")
else
local w, h = pngSize(png)
eq(w, 128, "pret's ruins_of_alph.png is 16 tiles across")
eq(h, 96, "and 12 down: $60 tiles per VRAM bank, two banks")
end
end
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
cache = (os.getenv("HOME") or "")
.. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local events = loadTable(cache .. "/data/generated/events.lua")
local tilesets = loadTable(cache .. "/data/generated/tilesets.lua")
-- data/events/unown_walls.asm:2-5
local EXPECTED = {
{ word = "ESCAPE", x1 = 3, y1 = 4, x2 = 16, y2 = 9 },
{ word = "LIGHT", x1 = 4, y1 = 4, x2 = 15, y2 = 9 },
{ word = "WATER", x1 = 4, y1 = 4, x2 = 15, y2 = 9 },
{ word = "HO-OH", x1 = 4, y1 = 4, x2 = 15, y2 = 9 },
}
if not events then
check(true, "no Crystal cache: the wall words are not read (SKIP)")
elseif not events.unownWalls then
check(true, "cache predates the wall words: re-import Crystal (SKIP)")
else
local walls = events.unownWalls
eq(#walls, 4, "NUM_UNOWN_WALLS rows came out")
for index, want in ipairs(EXPECTED) do
local got = walls[index] or {}
eq(got.word, want.word, want.word .. " decoded through the Unown charmap")
eq(got.id, index - 1, "at the UNOWNWORDS_* value the setval uses")
eq(#(got.chars or {}), #want.word, "with one character byte per letter")
eq(got.x1, want.x1, want.word .. "'s box left edge")
eq(got.y1, want.y1, "top edge")
eq(got.x2, want.x2, "right edge")
eq(got.y2, want.y2, "bottom edge")
-- data/events/unown_walls.asm:20 MENU_BACKUP_TILES
eq(got.flags, 0x40, "and MENU_BACKUP_TILES set")
-- data/events/unown_walls.asm:21 `menu_coords 9 - n, 4, 10 + n, 9`
local _, _, bw = UnownWords.boxRect(got)
eq(bw - 2, #want.word * 2, "the frame is 2 columns per letter wide")
end
-- engine/events/unown_walls.asm:249 .DashChar
local dash = UnownWords.layout(walls[4])[3]
eq(dash.tl, 0x02, "HO-OH's dash comes out of .DashChar, not the alphabet")
end
if not tilesets then
check(true, "no Crystal cache: the tileset sheet is not read (SKIP)")
else
local roa = tilesets.TILESET_RUINS_OF_ALPH
if not roa then
check(false, "the Ruins of Alph tileset is in the cache")
elseif roa.imageHeight ~= 128 then
check(true, "cache predates the two-bank sheet: re-import Crystal (SKIP)")
else
eq(roa.imageWidth, 128, "the sheet is 16 tiles across")
eq(roa.imageHeight, 128, "and 16 down: 256 tile ids, both VRAM banks")
eq(roa.tilesPerRow, 16, "so a tile id indexes it directly")
eq(#roa.tilePalettes, 224, "the PalMap covers every id the blocks use")
local png = readFile(cache .. "/assets/generated/tilesets/ruins_of_alph.png")
if not png then
check(false, "the sheet PNG is actually in the cache")
else
local w, h = pngSize(png)
eq(w, 128, "the written PNG is 128 wide")
eq(h, 128, "and 128 tall")
end
-- constants/tileset_constants.asm:34-38, the five Crystal-only tilesets.
for _, name in ipairs({ "TILESET_KABUTO_WORD_ROOM",
"TILESET_OMANYTE_WORD_ROOM", "TILESET_AERODACTYL_WORD_ROOM",
"TILESET_HO_OH_WORD_ROOM", "TILESET_BETA_WORD_ROOM" }) do
local set = tilesets[name]
if not set then
check(false, name .. " extracted")
else
eq(set.imageHeight, 128, name .. " is a two-bank sheet")
-- engine/tilesets/map_palettes.asm:40 -- bit 7 is the VRAM bank.
local high = 0
for _, block in ipairs(set.blocks) do
for _, tile in ipairs(block) do
if tile >= 0x80 and tile < 0xe0 then high = high + 1 end
end
end
check(high > 0, name .. " really does draw out of bank 1")
end
end
end
end
-- data/events/special_pointers.asm:151 add_special DisplayUnownWords
do
local Specials = require("src.script.gen2.Specials")
check(type(Specials.ALL.DisplayUnownWords) == "function",
"DisplayUnownWords is a handler and not a stub")
eq(Specials.HANDLER_SOURCE.DisplayUnownWords,
"specials/unown_words.lua", "owned by this unit's module")
end
do
local maps = loadTable(cache .. "/data/generated/maps.lua")
local scripts = loadTable(cache .. "/data/generated/scripts.lua")
local consts = loadTable(cache .. "/data/generated/constants.lua")
if not (maps and scripts and consts and events and events.unownWalls) then
check(true, "no Crystal cache: the chambers are not walked (SKIP)")
else
local index
for id, name in pairs(consts.specialOrder or {}) do
if name == "DisplayUnownWords" then index = id - 1 end
end
check(index ~= nil, "the cache's specialOrder names DisplayUnownWords")
local chambers = {
RUINS_OF_ALPH_KABUTO_CHAMBER = 0,
RUINS_OF_ALPH_AERODACTYL_CHAMBER = 1,
RUINS_OF_ALPH_OMANYTE_CHAMBER = 2,
RUINS_OF_ALPH_HO_OH_CHAMBER = 3,
}
for mapId, want in pairs(chambers) do
local def = maps[mapId]
local walls = 0
for _, bg in ipairs((def or {}).bgEvents or {}) do
local rows = bg.scriptKey and scripts[bg.scriptKey]
local pending
for _, row in ipairs(rows or {}) do
if row.op == "setval" then
pending = row.value or (row.args and row.args[1])
elseif row.op == "special" and row.id == index then
walls = walls + 1
eq(pending, want, mapId .. " asks for its own word")
check(UnownWords.wallFor(
{ gen2EventTables = events }, pending) ~= nil,
"and that value indexes a real wall row")
end
end
end
eq(walls, 2, mapId .. " has both of its wall patterns")
end
end
end
S.finish()
+231
View File
@@ -0,0 +1,231 @@
-- The faithfulness convention: Gold and Silver keep their original cart bugs
-- wherever the bug is not hardware-dependent, and Crystal gets the fixes
-- Crystal shipped. GameVersion.fixes(id) names the FIX, so an absent table
-- reads as "bugged like the cart".
-- luajit tests/gen2_version_fixes_test.lua
--
-- Four of pokegold's seven documented bugs are engine behaviour this port can
-- express. This file pins the two that are reachable from Lua state:
--
-- luckyNumberBoxes the Lucky Number Show's loop bound
-- halloffame the first-save PC corruption, which is NOT reachable
-- here and is pinned as such rather than emulated
--
-- surfOntoNpc lives with the field-move unit and reflectOverflow with the
-- battle unit; only the flag names are asserted here.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 version fixes")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GameVersion = require("src.core.GameVersion")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
local priorVersion = GameVersion.get()
-- ---- CT-10: the API shape -------------------------------------------------
eq(type(GameVersion.fixes), "function", "GameVersion.fixes is an accessor")
eq(type(GameVersion.fixes("crystal")), "table", "and it returns a table")
for _, id in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
eq(next(GameVersion.fixes(id)), nil, id .. " fixes nothing")
end
eq(next(GameVersion.fixes("nonesuch")), nil, "an unknown id reads as {}")
for _, name in ipairs({ "luckyNumberBoxes", "surfOntoNpc", "reflectOverflow" }) do
eq(GameVersion.fixes("crystal")[name], true, "crystal fixes " .. name)
end
do
local count = 0
for _ in pairs(GameVersion.fixes("crystal")) do count = count + 1 end
eq(count, 3, "and carries no fix name a consumer was not told about")
end
GameVersion.set("gold")
eq(next(GameVersion.fixes()), nil, "the active version is the default subject")
GameVersion.set("crystal")
eq(GameVersion.fixes().luckyNumberBoxes, true, "which follows GameVersion.set")
-- Gold's row must stay bug-shaped even if someone later hangs data off it.
GameVersion.set("gold")
check(GameVersion.info("gold").fixes == nil, "the gold row has no fixes table")
check(GameVersion.info("silver").fixes == nil, "nor does silver")
-- ---- the Lucky Number Show, boxes 10-14 -----------------------------------
--
-- pokegold/engine/events/lucky_number.asm:100-102 bounds .BoxesLoop with
-- NUM_BOXES_JP (9, pokegold/constants/pokemon_data_constants.asm:123) where
-- pokecrystal/engine/events/lucky_number.asm:99 uses NUM_BOXES (14). The OPEN
-- box is walked out of sBox before that loop starts and the loop then skips
-- it, so the current box is searched on both carts whatever its number.
local function boxSet(order)
local seen = {}
for _, index in ipairs(order) do seen[index] = true end
return seen
end
GameVersion.set("gold")
do
local order = Specials.luckyNumberBoxOrder({ currentBox = 1 })
eq(#order, 9, "Gold walks nine boxes")
eq(order[1], 1, "starting with the open one")
local seen = boxSet(order)
for index = 1, 9 do check(seen[index], "gold searches box " .. index) end
for index = 10, 14 do
check(not seen[index], "gold skips box " .. index .. " (the cart bug)")
end
end
do
local order = Specials.luckyNumberBoxOrder({ currentBox = 12 })
eq(order[1], 12, "the open box is searched first even at 12")
eq(#order, 10, "on top of the nine .BoxesLoop reaches")
check(boxSet(order)[12], "so box 12 is not skipped while it is open")
end
GameVersion.set("crystal")
do
local order = Specials.luckyNumberBoxOrder({ currentBox = 1 })
eq(#order, 14, "Crystal walks all fourteen")
local seen = boxSet(order)
for index = 1, 14 do check(seen[index], "crystal searches box " .. index) end
end
do
local order = Specials.luckyNumberBoxOrder({})
eq(order[1], 1, "a save with no currentBox opens box 1")
eq(#order, 14, "and still walks the full set")
end
-- The handler itself, over the same fixture on both editions. The party mon
-- shares three trailing digits (second prize, wScriptVar 2); the mon in box 12
-- is an exact match (first prize, wScriptVar 1), and the BEST match wins.
local function luckyVm(record)
return {
specials = {
save = function() return record end,
party = function() return record.party end,
monName = function(species) return species end,
},
setStringBuffer = function(self, value) self.stringBuffer = value end,
}
end
local function luckyFixture(boxIndex, currentBox)
local record = {
luckyNumber = 12345,
currentBox = currentBox or 1,
party = { { species = "CHIKORITA", otId = 99345 } },
boxes = {},
}
record.boxes[boxIndex] = { { species = "MAGIKARP", otId = 12345 } }
return record
end
GameVersion.set("gold")
do
local vm = luckyVm(luckyFixture(3))
Specials.HANDLERS.CheckForLuckyNumberWinners(vm)
eq(vm.scriptVar, 1, "Gold finds an exact match in box 3")
check(vm.luckyNumberInBox, "and reports it came from a box")
end
do
local vm = luckyVm(luckyFixture(12))
Specials.HANDLERS.CheckForLuckyNumberWinners(vm)
eq(vm.scriptVar, 2, "Gold never sees the same mon in box 12")
check(not vm.luckyNumberInBox, "so the party's partial match wins instead")
end
do
local vm = luckyVm(luckyFixture(12, 12))
Specials.HANDLERS.CheckForLuckyNumberWinners(vm)
eq(vm.scriptVar, 1, "unless box 12 is the box the PC has open")
end
GameVersion.set("crystal")
do
local vm = luckyVm(luckyFixture(12))
Specials.HANDLERS.CheckForLuckyNumberWinners(vm)
eq(vm.scriptVar, 1, "Crystal finds box 12 without opening it")
check(vm.luckyNumberInBox, "and reports it came from a box")
end
do
local vm = luckyVm(luckyFixture(14))
Specials.HANDLERS.CheckForLuckyNumberWinners(vm)
eq(vm.scriptVar, 1, "and box 14, the last one")
end
-- ---- the Hall of Fame, first-save PC corruption ---------------------------
--
-- pokegold/engine/events/halloffame.asm:17 is the bug marker; Crystal farcalls
-- HallOfFame_InitSaveIfNeeded there (pokecrystal/engine/menus/save.asm:470-475)
-- to run ErasePreviousSave over uninitialised SRAM. This port has no SRAM, so
-- the induction is pinned as harmless here rather than emulated.
GameVersion.set("gold")
do
local save = Save.newGame({ name = "GOLD", gender = "male" })
save.boxes[7] = { { species = "SUDOWOODO", level = 20, otId = 111 } }
save.boxNames[7] = "TREES"
save.currentBox = 7
eq(HallOfFame.count(save), 0, "a fresh save has never been inducted")
check(not HallOfFame.hasEntered(save), "and carries no status flag")
local party = { { species = "TYPHLOSION", level = 55, otId = 222,
nickname = "TYPHLO" } }
local entry, wasEntered = HallOfFame.induct(save, party)
check(entry ~= nil, "induction on a never-saved file returns a row")
check(not wasEntered, "and reports the player was not already a champion")
eq(HallOfFame.count(save), 1, "the win counter reaches one")
eq(#save.boxes[7], 1, "box 7 still holds its mon")
eq(save.boxes[7][1].species, "SUDOWOODO", "unchanged")
eq(save.boxNames[7], "TREES", "its name survives too")
eq(save.currentBox, 7, "and the open box is still open")
for index = 1, Save.NUM_BOXES do
local box = save.boxes[index]
check(box == nil or type(box) == "table",
"box " .. index .. " is a table or absent, never garbage")
end
end
-- ---- the Coin Case terminator ---------------------------------------------
--
-- pokegold/data/text/common_3.asm:341 ends _CoinCaseCountText with `done` ($57)
-- where pokecrystal/data/text/common_3.asm:1308 uses `text_end`, and
-- pokegold/home/text.asm:590-593 stops only on TX_END. The port decodes the
-- stream once at import (decodeGen2Text breaks on $57), so the cache is the
-- evidence that nothing runs past the string.
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local textPath = cache .. "/data/generated/rom_text.lua"
local tf = io.open(textPath, "r")
if not tf then
check(true, "rom_text.lua absent : no gold cache (SKIP cache facts)")
else
tf:close()
local romText = assert(loadfile(textPath))()
eq(romText._CoinCaseCountText, "Coins:\n{NUM}",
"the `done` terminator still ends the decoded string")
check(not tostring(romText._CoinCaseCountText or ""):find("Raise the PP"),
"and nothing from the next label runs into it")
end
GameVersion.set(priorVersion)
S.finish()
+5 -3
View File
@@ -2357,9 +2357,11 @@ check(Specials.STUBS.PhotoStudio == nil,
"PhotoStudio is a HANDLER now: the conversation and the portrait card exist")
check(Specials.HANDLERS.PhotoStudio ~= nil,
"and it is the one that special dispatch resolves to")
check(Specials.STUBS.PrintDiploma ~= nil,
"PrintDiploma stays stubbed -- it is nothing but the print, with no screen "
.. "half of its own")
check(Specials.STUBS.PrintDiploma == nil
and Specials.HANDLERS.PrintDiploma ~= nil,
"PrintDiploma is a handler now: _PrintDiploma opens on the very page "
.. "`special Diploma` shows (engine/printer/printer.asm:382) and only the "
.. "two SendScreenToPrinter passes wanted a printer")
check(Specials.STUBS.UnownPrinter == nil
and Specials.HANDLERS.UnownPrinter ~= nil,
"UnownPrinter is a handler for the same reason this one is: the stamp "
+1 -1
View File
@@ -286,7 +286,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap",
"faithfulRes", "screenPos", "fpsCap",
"speedOverworld", "speedBattle", "speedMenu",
"mods", "controls", "dateFormat", "timeFormat" }
local function orow(menu, id)
+5 -3
View File
@@ -48,7 +48,8 @@ end
local ZONE0 = {
{ "red", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "blue", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "yellow", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
-- pokeyellow/data/sgb/sgb_palettes.asm:35 PAL_GREENBAR
{ "yellow", "gbc", { 255, 255, 247 }, { 255, 255, 156 }, { 0, 173, 0 }, { 49, 49, 49 } },
{ "red", "ogred", { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } },
{ "blue", "ogred", { 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 } },
{ "yellow", "ogred", { 255, 255, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 25, 25, 25 } },
@@ -218,8 +219,9 @@ local ANIM = {
{ "blue", "ogred", { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } },
{ "red", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "blue", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "yellow", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "yellow", "ogred", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
-- pokeyellow/data/sgb/sgb_palettes.asm:35 PAL_GREENBAR
{ "yellow", "gbc", { 255, 255, 247 }, { 49, 49, 49 }, { 49, 49, 49 } },
{ "yellow", "ogred", { 255, 255, 247 }, { 49, 49, 49 }, { 49, 49, 49 } },
}
for _, c in ipairs(ANIM) do
+6 -6
View File
@@ -73,14 +73,14 @@ end
-- === #250: the CATERPIE / WEEDLE description ===
-- pokered/text/ViridianCity.asm declares this one without the leading
-- underscore the extractor keys on, so it is not in data/generated/text.lua
-- and the port carries a literal. That makes the literal load-bearing.
-- underscore, and the extractor now keys on both spellings, so the port's
-- literal has to keep agreeing with what the cart actually says.
do
check(Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil
and Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil,
"the description really is absent from generated text (hence a literal)")
local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!"
local extracted = Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
or Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText
if extracted then eq(extracted, desc, "the literal still matches the cart") end
local pages = assertPlayable("caterpillar description", desc)
eq(#pages, 2, "para breaks the description into two pages (#250)")
eq(#pages[1], 3, "page 1 is text + line + cont")
+5 -4
View File
@@ -76,11 +76,12 @@ eq(ri4.tab, "blue", "an explicit --game tab beats the remembered version")
LaunchOptions.pendingTab = nil -- module is a singleton: do not leak this
-- A junk value in options.lua (hand-edited file, a build that knew other
-- versions) must not select a tab that does not exist. "gold" used to be
-- the stand-in for this (Gen 2's Gold/Silver support was still unwritten);
-- now that Gold is a real version, "crystal" is the still-unknown one.
-- versions) must not select a tab that does not exist. Real version ids
-- stood here twice -- "gold", then "crystal" -- and each had to be swapped
-- out the day that game shipped, so this is "nonesuch": a deliberate
-- never-a-game token, not a version anyone is waiting on.
local opts = SaveData.loadOptions()
opts.lastVersion = "crystal"
opts.lastVersion = "nonesuch"
SaveData.saveOptions(opts)
local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } })
ri5:_applyLastVersionTab()
+50
View File
@@ -0,0 +1,50 @@
-- T2 Gen 2 tier: tests/gen2_*.lua, tests/crystal_*.lua and the LZ3 decoder, one
-- process per suite. ROM-free, so it runs with no data/generated/ and no cache.
-- luajit tests/run_gen2.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local FsIo = require("tests.fs_io")
local PREFIXES = { "gen2_", "crystal_" }
local EXTRA = { "tests/rom_lz3_test.lua" }
local function suites()
local files = {}
for _, name in ipairs(FsIo.listDir("tests")) do
if name:match("%.lua$") then
for _, prefix in ipairs(PREFIXES) do
if name:sub(1, #prefix) == prefix then
files[#files + 1] = "tests/" .. name
break
end
end
end
end
for _, path in ipairs(EXTRA) do
local handle = io.open(path, "r")
if handle then
handle:close()
files[#files + 1] = path
end
end
table.sort(files)
return files
end
local lua = (arg and arg[-1]) or "luajit"
local failed, total = 0, 0
for _, path in ipairs(suites()) do
total = total + 1
local status = os.execute(("%s %s"):format(lua, path))
if status == 0 or status == true then
print("ok " .. path)
else
failed = failed + 1
print("FAIL " .. path)
end
end
print(("\ngen2: %d/%d suites passed"):format(total - failed, total))
print(("%s"):format(failed == 0 and "ALL TESTS PASSED" or failed .. " FAILURES"))
os.exit(failed == 0 and 0 or 1)
+20 -12
View File
@@ -1338,7 +1338,7 @@ do
Game.save.pokedex.owned.EKANS = nil
local cb = BattleState.newWild(Game, "EKANS", 5)
cb:storeCaughtMon()
check(hasText(cb, "New POKéDEX data\nwill be added for\nEKANS!"),
check(hasText(cb, "New POKéDEX data\nwill be added for\vEKANS!"),
"_ItemUseBallText06 on a first catch")
check(Game.save.pokedex.owned.EKANS == true, "species registered as owned")
eq(cb.result, "caught", "catch resolves the battle")
@@ -1348,7 +1348,7 @@ do
for _ = 1, 6 do table.insert(Game.save.party, Pokemon.new(Data, "RATTATA", 5)) end
local cb2 = BattleState.newWild(Game, "EKANS", 5)
cb2:storeCaughtMon()
check(hasText(cb2, "EKANS was\ntransferred to\nsomeone's PC!"),
check(hasText(cb2, "EKANS was\ntransferred to\vsomeone's PC!"),
"_ItemUseBallText08 before meeting Bill")
check(not hasText(cb2, "New POKéDEX data"),
"no dex page for an already-owned species")
@@ -1372,7 +1372,7 @@ do
Game.save.flags.EVENT_MET_BILL = true
local cb3 = BattleState.newWild(Game, "EKANS", 5)
cb3:storeCaughtMon()
check(hasText(cb3, "EKANS was\ntransferred to\nBILL's PC!"),
check(hasText(cb3, "EKANS was\ntransferred to\vBILL's PC!"),
"_ItemUseBallText07 after meeting Bill")
Game.save.flags.EVENT_MET_BILL = nil
@@ -3485,10 +3485,14 @@ runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- launcher last played tab (#835)
runSuites({ "tests/rom_importer_last_version_test.lua" })
-- ---------------------------------------------- Gold (Gen 2)
-- All ROM-free: each file carries its own fixtures shaped like the extractor's
-- output, so they run without a Gold cache.
runSuites({
-- ---------------------------------------------- Gold / Crystal (Gen 2)
-- All ROM-free: own fixtures, or a self-skip on a missing cache. Globbed on
-- the historical chain; tests/run_gen2.lua runs the same set, one per process.
local LEAKS_SAVE_SLOT_STATE = {
["tests/gen2_save_export_test.lua"] = true,
}
runSuites(orderedGlob(
"tests/gen2_*.lua tests/crystal_*.lua tests/rom_lz3_test.lua", {
"tests/rom_lz3_test.lua",
"tests/gen2_world_test.lua",
"tests/gen2_audio_test.lua",
@@ -3599,10 +3603,8 @@ runSuites({
-- name resolution, and the .sav converter refusing a Gen 2 save table.
"tests/gen2_sound_alias_test.lua",
"tests/gen2_save_convert_cli_test.lua",
-- The wall radios (`special MapRadio`); the Pokegear-proper suites next to
-- it (gen2_pokegear_unlock_test, gen2_save_export_test) stay out of this
-- block because their headers ask for a GOLD_CACHE, which this tier cannot
-- assume.
-- The wall radios (`special MapRadio`). gen2_save_export_test cannot share a
-- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone.
"tests/gen2_map_radio_test.lua",
-- The two seams where a battle meets everything else: what ends a round and
-- a battle (and what a battle may not leave on the party), and BattlePack --
@@ -3613,7 +3615,13 @@ runSuites({
-- which failures suppress the attack animation, and the Rollout /
-- EFFECT_RAMPAGE lock-ins. ROM-free like the rest of this block.
"tests/gen2_battle_lockin_test.lua",
})
-- Crystal rides the same glob: crystal_*.lua and the gen2_crystal_* files.
"tests/crystal_import_test.lua",
"tests/crystal_world_test.lua",
"tests/gen2_crystal_anim_test.lua",
"tests/gen2_crystal_caught_data_test.lua",
"tests/gen2_crystal_gender_test.lua",
}, LEAKS_SAVE_SLOT_STATE))
-- ---------------------------------------------- Android second ROM pick (#167)
runSuites({ "tests/rom_importer_android_pick_test.lua" })
+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