Files
gen1recomp/tests/emerald_match_call_test.lua
T
bryanthaboi 690f0cd9a1 TOUCHDOWN
2026-09-27 16:07:31 -04:00

325 lines
16 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local ROM_PATH = os.getenv("POKEPORT_EMERALD_ROM") or "../pokeemerald/pokeemerald.gba"
local PRET = os.getenv("POKEPORT_PRET_EMERALD") or "../pokeemerald"
local f = io.open(ROM_PATH, "rb")
if not f then
print("emerald_match_call_test: skipped (no ROM at " .. ROM_PATH .. ")")
os.exit(0)
end
local data = f:read("*a")
f:close()
if data:sub(0xAD, 0xB0) ~= "BPEE" then
print("emerald_match_call_test: skipped (" .. ROM_PATH .. " is not Emerald)")
os.exit(0)
end
local rom = { id = "emerald", size = #data }
function rom.get(_, o) return data:byte(o + 1) end
function rom.u16(_, o) local a, b = data:byte(o + 1, o + 2); return a + b * 256 end
function rom.u32(_, o)
local a, b, c, d = data:byte(o + 1, o + 4)
return a + b * 256 + c * 65536 + d * 16777216
end
function rom.readString(_, o, n) return data:sub(o + 1, o + n) end
local files = {}
local cache = {
write = function(_, rel, bytes) files[rel] = bytes; return true end,
read = function(_, rel) return files[rel] end,
}
local K = require("src.import.gba.rse.boot_gfx")
local ROOT = "data/generated/gba"
local MCX = require("src.import.gba.rse.match_call_extract")
local PNX = require("src.import.gba.rse.pokenav_extract")
for _, M in ipairs({ MCX, PNX }) do
check(not M.ready(cache, ROOT), M.SUB .. " not ready before run")
local ok = M.run(rom, cache, { cacheRoot = ROOT })
eq(ok, true, M.SUB .. " extractor ran")
check(M.ready(cache, ROOT), M.SUB .. " ready after run")
for _, rel in ipairs(M.REQUIRED) do check(files[ROOT .. "/" .. rel] ~= nil, rel .. " written") end
end
local function manifestOf(sub)
return assert(load(files[ROOT .. "/" .. sub .. "/manifest.lua"], "@m", "t", {}))()
end
local mc = manifestOf(MCX.SUB)
local nav = manifestOf(PNX.SUB)
local C = require("src.core.game3.constants").of("emerald")
local function readPret(rel)
local h = io.open(PRET .. "/" .. rel, "rb")
if not h then return nil end
local s = h:read("*a")
h:close()
return s
end
-- pokeemerald/src/match_call.c:171
local src = readPret("src/match_call.c")
if src then
local body = src:match("sMatchCallTrainers%[%]%s*=%s*(%b{})")
local ids = {}
for name in (body or ""):gmatch("%.trainerId%s*=%s*(TRAINER_[%w_]+)") do ids[#ids + 1] = name end
eq(#ids, 64, "sMatchCallTrainers parsed from pret")
local n = 0
while mc.trainers[n] do n = n + 1 end
eq(n, #ids, "sMatchCallTrainers row count")
for i, name in ipairs(ids) do
eq(mc.trainers[i - 1].trainerId, C.trainers.byName[name], "sMatchCallTrainers[" .. (i - 1) .. "] " .. name)
end
end
eq(#mc.battleTopics, 3, "three battle topics")
eq(#mc.battleTopics[1], 15, "sMatchCallWildBattleTexts has 15 rows")
eq(mc.battleTopics[1][1].text, "MatchCall_WildBattleText1", "wild battle text 1 by symbol")
eq(table.concat(mc.battleTopics[1][1].vars, ","), "0,2,-1", "STRS_WILD_BATTLE string var ids")
eq(#mc.generalTopics, 6, "six general topics")
eq(#mc.generalTopics[1], 64, "64 personalized texts")
local headerNames = {}
local hsrc = readPret("src/pokenav_match_call_data.c")
if hsrc then
local body = hsrc:match("sMatchCallHeaders%[%]%s*=%s*(%b{})")
for kind in (body or ""):gmatch("{%s*%.(%a+)%s*=") do headerNames[#headerNames + 1] = kind end
local n = 0
while mc.headers[n] do n = n + 1 end
eq(n, #headerNames, "sMatchCallHeaders count (" .. #headerNames .. ")")
for i, kind in ipairs(headerNames) do
eq(mc.headers[i - 1].type, kind == "leader" and "leader" or kind, "sMatchCallHeaders[" .. (i - 1) .. "] type " .. kind)
end
end
eq(mc.headers[0].name, "gText_MrStoneMatchCallName", "header 0 is Mr. Stone")
eq(mc.headers[9].rematchTableIdx, 65, "Roxanne header points at REMATCH_ROXANNE")
eq(#mc.checkPageOverrides, 4, "four check page overrides")
eq(#mc.gymLeaderRematchesAfterNewMauville, 8, "GymLeaderRematches_AfterNewMauville")
eq(#mc.gymLeaderRematchesBeforeNewMauville, 7, "GymLeaderRematches_BeforeNewMauville")
local VT = require("src.import.gba.versions_text_emerald")
local have = {}
for _, n in ipairs(VT.NAMED_TEXTS) do have[n] = true end
local missing = {}
local function need(k) if k and not have[k] then missing[#missing + 1] = k end end
for _, list in ipairs({ mc.battleTopics, mc.requestTopics, mc.generalTopics }) do
for _, t in ipairs(list) do for _, r in ipairs(t) do need(r.text) end end
end
local hi = 0
while mc.headers[hi] do
local h = mc.headers[hi]
need(h.desc); need(h.name)
for _, d in ipairs(h.textData or {}) do need(d.text) end
hi = hi + 1
end
for i = 0, 77 do for _, t in ipairs(mc.flavorTexts[i] or {}) do need(t) end end
for i = 0, #nav.pageDescriptions do need(nav.pageDescriptions[i]) end
for i = 0, #nav.helpBarTexts do need(nav.helpBarTexts[i]) end
for _, row in ipairs(nav.landmarks) do for _, l in ipairs(row.landmarks) do need(l.name) end end
eq(#missing, 0, "every match call / pokenav text key is in the ROM text bundle (" .. table.concat(missing, ",") .. ")")
eq(nav.menus[1].yStart, 42, "UNLOCK_MC option labels start at y 42")
eq(#nav.menus[4].items, 6, "condition search menu has six labels")
eq(#nav.cityMaps, 22, "22 PokeNav city maps")
eq(nav.pageDescriptions[0], "gText_CheckMapOfHoenn", "HOENN MAP description")
local tmp = os.tmpname()
os.remove(tmp)
os.execute('mkdir -p "' .. tmp .. '"')
local function dump(rel, name)
local h = assert(io.open(tmp .. "/" .. name, "wb"))
h:write(files[ROOT .. "/" .. rel])
h:close()
end
dump("rse/pokenav/spin.png", "spin.png")
dump("rse/pokenav/blue_light.png", "blue_light.png")
dump("rse/pokenav/cursor_large.png", "cursor_large.png")
dump("rse/pokenav/lh_match_call.png", "lh_match_call.png")
dump("rse/pokenav/lh_hoenn_map.png", "lh_hoenn_map.png")
dump("rse/pokenav/options.png", "options.png")
dump("rse/match_call/window.png", "window.png")
dump("rse/match_call/nav_icon.png", "mc_nav_icon.png")
local script = [[
import sys
from PIL import Image
tmp, pret = sys.argv[1], sys.argv[2] + "/graphics/pokenav/"
def idx(p): return Image.open(p).convert("P")
def diff(a, b, box=None, off=(0, 0)):
A, B = idx(a), idx(b)
w, h = A.size if box is None else (box[2], box[3])
n = 0
for y in range(h):
for x in range(w):
ax, ay = (x, y) if box is None else (box[0] + x, box[1] + y)
if A.getpixel((ax, ay)) != B.getpixel((off[0] + x, off[1] + y)): n += 1
return n
out = {}
out["spin"] = diff(tmp + "/spin.png", pret + "nav_icon.png")
out["blue_light"] = diff(tmp + "/blue_light.png", pret + "blue_light.png")
out["cursor_large"] = diff(tmp + "/cursor_large.png", pret + "region_map/cursor_large.png")
out["lh_match_call"] = diff(tmp + "/lh_match_call.png", pret + "left_headers/match_call.png")
out["lh_hoenn_map"] = diff(tmp + "/lh_hoenn_map.png", pret + "left_headers/hoenn_map.png")
out["mc_window"] = diff(tmp + "/window.png", pret + "match_call/window.png")
out["mc_nav_icon"] = diff(tmp + "/mc_nav_icon.png", pret + "match_call/nav_icon.png")
n = 0
for j in range(4):
n += diff(tmp + "/options.png", pret + "options/hoenn_map.png", (j * 32, 0, 32, 16), (0, j * 16))
out["option_hoenn_map"] = n
print(" ".join("%s=%d" % (k, out[k]) for k in sorted(out)))
]]
local sf = io.open(tmp .. "/cmp.py", "wb")
sf:write(script)
sf:close()
local py = io.popen("python3 -c 'import PIL' 2>/dev/null && echo yes")
local havePil = py and py:read("*l") == "yes"
if py then py:close() end
if havePil and readPret("graphics/pokenav/nav_icon.png") then
local p = io.popen('python3 "' .. tmp .. '/cmp.py" "' .. tmp .. '" "' .. PRET .. '" 2>&1')
local line = p and p:read("*a") or ""
if p then p:close() end
print("emerald_match_call_test: differing pixels " .. line:gsub("%s+$", ""))
for _, key in ipairs({ "spin", "blue_light", "cursor_large", "lh_match_call", "lh_hoenn_map", "mc_window", "mc_nav_icon",
"option_hoenn_map" }) do
eq(tonumber(line:match(key .. "=(%d+)")), 0, key .. " matches pret graphics/pokenav pixel for pixel")
end
else
print("emerald_match_call_test: PIL or pret graphics missing, pixel compare skipped")
end
os.execute('rm -f "' .. tmp .. '"/*.png "' .. tmp .. '"/cmp.py; rmdir "' .. tmp .. '"')
package.loaded["src.core.game3.dataset"] = {
cache = function() return cache end,
}
local trainerRows = {}
local rematches = {}
local bsrc = readPret("src/battle_setup.c")
local ok = bsrc ~= nil
if ok then
local body = bsrc:match("gRematchTable%[REMATCH_TABLE_ENTRIES%]%s*=%s*(%b{})")
local i = 0
for a1, a2, a3, a4, a5, map in (body or ""):gmatch("REMATCH%((TRAINER_[%w_]+),%s*(TRAINER_[%w_]+),%s*(TRAINER_[%w_]+),%s*(TRAINER_[%w_]+),%s*(TRAINER_[%w_]+),%s*(MAP_[%w_]+)%)") do
local m = C.map_groups.byName[map]
rematches[i] = { trainers = { C.trainers.byName[a1], C.trainers.byName[a2], C.trainers.byName[a3],
C.trainers.byName[a4], C.trainers.byName[a5] }, mapGroup = m.group, mapNum = m.num }
i = i + 1
end
eq(i, 78, "gRematchTable parsed from pret (78)")
end
local Trainers = require("src.core.game3.scripting.trainers")
local CINDY, CINDY_3, CINDY_4 = C.trainers.byName.TRAINER_CINDY_1, C.trainers.byName.TRAINER_CINDY_3,
C.trainers.byName.TRAINER_CINDY_4
trainerRows[CINDY] = { name = "CINDY", class = 1, className = "LADY", party = { { species = 1, level = 7 } } }
trainerRows[CINDY_3] = { name = "CINDY", class = 1, className = "LADY", party = { { species = 2, level = 20 } } }
Trainers._pack = { trainers = trainerRows, rematches = rematches }
local Rematch = require("src.core.game3.rse.rematch")
local MatchCall = require("src.core.game3.rse.match_call")
local Flags = require("src.core.game3.scripting.flags")
local session = { version = "emerald", flags = {}, vars = {}, party = {}, trainerId = 0x1234, gameStats = {} }
local function setFlag(name, on) Rematch.setFlag(session, name, on) end
eq(Rematch.count(), 78, "rematch table size")
local idx = Rematch.firstBattleTableId(CINDY)
eq(idx, 10, "Cindy is REMATCH_CINDY")
eq(Rematch.tableIdOf(CINDY_3), 10, "CINDY_3 resolves to the same row")
check(not Rematch.shouldTryRematchBattle(session, CINDY), "no rematch before Cindy wants one")
setFlag(Flags.trainerFlagId(CINDY), true)
eq(Rematch.rematchTrainerId(session, CINDY), CINDY_3, "GetRematchTrainerId: first rematch is CINDY_3")
Rematch.set(session, idx, 1)
check(Rematch.shouldTryRematchBattle(session, CINDY), "ShouldTryRematchBattle once trainerRematches is set")
check(Rematch.isTrainerReadyForRematch(session, CINDY_3), "IsTrainerReadyForRematch for the rematch party")
Rematch.onRematchBattleWon(session, CINDY_3)
eq(Rematch.get(session, idx), 0, "rematch win clears trainerRematches")
check(Rematch.hasTrainerBeenFought(session, CINDY_3), "rematch win sets TRAINER_CINDY_3")
eq(Rematch.rematchTrainerId(session, CINDY), CINDY_4, "next rematch climbs to CINDY_4")
check(Rematch.shouldTryRematchBattle(session, CINDY), "WasSecondRematchWon keeps ShouldTryRematchBattle true")
Rematch.registerTrainerInMatchCall(session, CINDY)
check(not Rematch.flag(session, Rematch.registeredFlagId(session, idx)), "no registration without FLAG_HAS_MATCH_CALL")
setFlag("FLAG_HAS_MATCH_CALL", true)
Rematch.onTrainerBattleWon(session, CINDY)
check(Rematch.flag(session, Rematch.registeredFlagId(session, idx)), "trainer win registers the trainer in Match Call")
for _ = 1, 300 do Rematch.incrementStepCounter(session) end
eq(session.trainerRematchStepCounter, 0, "rematch step counter needs five badges")
for i = 1, 5 do setFlag(string.format("FLAG_BADGE%02d_GET", i), true) end
for _ = 1, 300 do Rematch.incrementStepCounter(session) end
eq(session.trainerRematchStepCounter, 255, "rematch step counter caps at 255")
local r104 = C.map_groups.byName.MAP_ROUTE104
Rematch.rng = function() return 0 end
check(Rematch.tryUpdateRandomTrainerRematches(session, r104.group, r104.num), "maxed counter rolls Route 104 rematches")
eq(Rematch.get(session, idx), 2, "SetRematchIdForTrainer picks the first unfought team (index 2)")
eq(session.trainerRematchStepCounter, 0, "rolling a rematch resets the step counter")
local mapsec = { EM_ROUTE104 = 17, EM_PETALBURG_CITY = 7 }
MatchCall.mapsecOfMap = function(_, mapId) return mapsec[mapId] or 99 end
local current = { mapType = 2, regionMapSectionId = 7 }
MatchCall.currentDef = function() return current end
MatchCall.mapName = function(sec) return "SEC" .. tostring(sec) end
local rolls = {}
MatchCall.rng = function() return table.remove(rolls, 1) or 0 end
local started = {}
MatchCall.startCall = function(_, _, opts) started[#started + 1] = opts return true end
local Rtc = require("src.core.game3.rtc")
Rtc.setFixed("2005-06-01T09:30:00")
MatchCall.initCounters(session)
for _ = 1, 9 do MatchCall.tryStartMatchCall(session, nil) end
eq(#started, 0, "no call before the tenth step")
rolls = { 0, 0 }
check(MatchCall.tryStartMatchCall(session, nil), "tenth step with a winning roll rings")
eq(started[1] and started[1].trainerId, CINDY, "the registered trainer calls")
for _ = 1, 10 do MatchCall.tryStartMatchCall(session, nil) end
eq(#started, 1, "the minute counter blocks a second call in the same ten minutes")
Rtc.advance(10)
for _ = 1, 10 do rolls = { 0, 0 } MatchCall.tryStartMatchCall(session, nil) end
eq(#started, 2, "ten minutes later the next tenth step can ring again")
current.mapType = 8
for _ = 1, 10 do rolls = { 0, 0 } Rtc.advance(10) MatchCall.tryStartMatchCall(session, nil) end
eq(#started, 2, "indoor maps never ring (Overworld_MapTypeAllowsTeleportAndFly)")
current.mapType = 2
Rematch.set(session, idx, 0)
setFlag(Flags.trainerFlagId(CINDY_4), false)
rolls = { 0 }
local msg = MatchCall.selectMessage(session, CINDY)
local cindyRow = mc.trainers[MatchCall.matchCallId(CINDY)]
local wantKey = mc.requestTopics[2][cindyRow.differentRouteTextId % 256].text
eq(msg.key, wantKey, "five badges on another route: different-route battle request")
check(msg.newRematchRequest, "the call is flagged as a new rematch request")
eq(Rematch.get(session, idx), 2, "UpdateRematchIfDefeated arms Cindy's rematch")
eq(msg.ctx.stringVars[1], "CINDY", "STR_TRAINER_NAME fills the trainer name")
eq(msg.ctx.stringVars[2], "SEC17", "STR_MAP_NAME fills Cindy's route")
current.regionMapSectionId = 17
msg = MatchCall.selectMessage(session, CINDY)
eq(msg.key, mc.requestTopics[1][cindyRow.sameRouteTextId % 256].text, "same route + eligible: same-route request")
-- pokeemerald/src/pokenav_match_call_list.c:205
setFlag("FLAG_ENABLE_MOM_MATCH_CALL", true)
local list = MatchCall.buildList(session)
eq(list[1].headerId, 0, "list starts with Mr. Stone")
eq(list[2].headerId, 6, "Mom follows when FLAG_ENABLE_MOM_MATCH_CALL is set")
eq(list[3].isSpecialTrainer, false, "registered trainers come after the special callers")
eq(list[3].headerId, idx, "Cindy's rematch index is listed")
check(MatchCall.showRematchIcon(session, list[3]), "pokeball icon for a trainer that wants a rematch")
local d, n = MatchCall.entryNameAndDesc(list[3])
eq(d .. "/" .. n, "LADY/CINDY", "trainer entries show class and name")
eq(MatchCall.entryFlavorText(session, list[3], 1), mc.flavorTexts[idx][1], "check page strategy text by rematch index")
check(not MatchCall.headerHasCheckPage(6), "Mom has no check page")
check(MatchCall.headerHasCheckPage(7), "Steven has a check page through sCheckPageOverrides")
-- pokeemerald/src/pokenav_match_call_data.c:1022
setFlag("FLAG_SYS_GAME_CLEAR", true)
local rox = mc.headers[9]
Rematch.set(session, rox.rematchTableIdx, 1)
local rm = MatchCall.headerMessage(session, 9)
eq(rm.key, "MatchCall_Text_Roxanne_RematchReady", "game clear + rematch ready: Roxanne asks for the rematch")
Rematch.set(session, rox.rematchTableIdx, 0)
eq(MatchCall.headerMessage(session, 9).key, "MatchCall_Text_Roxanne_PreparingPostGame", "no rematch yet: preparing post game")
local stone = MatchCall.headerMessage(session, 0)
eq(stone.key, "MatchCall_Text_MrStone11", "Mr. Stone picks the last available line")
Rtc.setFixed(nil)
T.finish()