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

388 lines
17 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local HOME = os.getenv("HOME") or ""
local LOVE_ROOTS = { HOME .. "/Library/Application Support/LOVE", HOME .. "/.local/share/love" }
local function metaOk(root, version, cacheVersion)
local f = io.open(root .. "/data/generated/gba/meta.json", "rb")
if not f then return false end
local src = (f:read("*a") or ""):gsub("%s", "")
f:close()
return src:find(('"cache_version":%d'):format(cacheVersion), 1, true) ~= nil
and (version ~= "emerald" or src:find('"version":"emerald"', 1, true) ~= nil)
end
local function findCache(version, cacheVersion, preferred)
for _, r in ipairs(LOVE_ROOTS) do
local names = {}
if preferred and preferred ~= "" then names[#names + 1] = preferred end
local pipe = io.popen('ls -1t "' .. r .. '" 2>/dev/null')
if pipe then
for name in pipe:lines() do names[#names + 1] = name end
pipe:close()
end
for _, name in ipairs(names) do
local root = r .. "/" .. name .. "/" .. version
if metaOk(root, version, cacheVersion) then return root, name end
end
end
return nil
end
local VersionsGame = require("src.import.gba.versions_game")
local identity = os.getenv("POKEPORT_IDENTITY")
local emHere = false
for _, r in ipairs(LOVE_ROOTS) do
if identity and identity ~= "" and metaOk(r .. "/" .. identity .. "/emerald", "emerald",
VersionsGame.game("emerald").CACHE_VERSION) then emHere = true end
end
if not emHere and os.getenv("POKEPORT_XVER_CHILD") ~= "1" then
local _, id = findCache("emerald", VersionsGame.game("emerald").CACHE_VERSION)
if not id then
print("emerald_link_xver_test: skipped (no current Emerald cache)")
os.exit(0)
end
local status = os.execute(("POKEPORT_XVER_CHILD=1 POKEPORT_IDENTITY='%s' %s tests/emerald_link_xver_test.lua")
:format(id, os.getenv("LUA") or "luajit"))
os.exit((status == true or status == 0) and 0 or 1)
end
local T = require("tests.harness")
T.verbose = true
local check, eq = T.check, T.eq
local GameVersion = require("src.core.GameVersion")
local Profile = require("src.core.game3.profile")
Profile.reset()
GameVersion.set("emerald")
local Dataset = require("src.core.game3.dataset")
local emCache = Dataset.cache()
local meta = emCache and emCache.read and emCache:read("data/generated/gba/meta.json")
if not (type(meta) == "string" and meta:find('"version":"emerald"', 1, true)) then
print("emerald_link_xver_test: skipped (no Emerald cache; set POKEPORT_IDENTITY)")
os.exit(0)
end
require("src.import.gba.versions").select("emerald")
require("src.core.game3.song_ids").select("emerald")
local Pokemon = require("src.core.game3.pokemon")
Pokemon.install(nil)
local ROOTS = {
emerald = findCache("emerald", VersionsGame.game("emerald").CACHE_VERSION, identity),
firered = findCache("firered", VersionsGame.game("firered").CACHE_VERSION, os.getenv("POKEPORT_FR_IDENTITY")),
leafgreen = findCache("leafgreen", VersionsGame.game("leafgreen").CACHE_VERSION, os.getenv("POKEPORT_LG_IDENTITY")),
}
for _, v in ipairs({ "emerald", "firered", "leafgreen" }) do
if not ROOTS[v] then
print("emerald_link_xver_test: skipped (no current " .. v .. " cache)")
os.exit(0)
end
print("[info] " .. v .. " cache at " .. ROOTS[v])
end
local function reader(root)
return function(rel)
local f = io.open(root .. "/" .. rel, "rb")
if not f then return nil end
local s = f:read("*a")
f:close()
return s
end
end
local Fingerprint = require("src.link.Fingerprint")
local Game3Link = require("src.link.Game3Link")
local Json = require("src.link.Json")
local Wire = require("src.link.Wire")
local HostRules = require("src.core.game3.link.host_rules")
local INPUTS = {}
for v, root in pairs(ROOTS) do INPUTS[v] = Fingerprint.gen3Inputs(reader(root)) end
local KINESIS, NATURE_POWER, SNATCH, PSYCHIC, BODY_SLAM, THUNDERBOLT, CROSS_CHOP = 134, 267, 289, 94, 34, 85, 238
local DIFF = { KINESIS, NATURE_POWER, SNATCH }
local FAMILY = { firered = "frlg", leafgreen = "frlg", emerald = "rse" }
print("[test] 1. the three battle_moves.h rows differ, the rest of the surface does not")
local frM, emM, lgM = INPUTS.firered.moves, INPUTS.emerald.moves, INPUTS.leafgreen.moves
check(frM[NATURE_POWER].accuracy ~= emM[NATURE_POWER].accuracy, "NATURE_POWER accuracy differs FR/EM")
check(frM[KINESIS].flags ~= emM[KINESIS].flags, "KINESIS flags differ FR/EM")
check(frM[SNATCH].flags ~= emM[SNATCH].flags, "SNATCH flags differ FR/EM")
local diffRows = 0
for id, row in pairs(frM) do
for _, f in ipairs(Fingerprint.GEN3_MOVE_FIELDS) do
if emM[id] == nil or emM[id][f] ~= row[f] then diffRows = diffRows + 1 break end
end
end
eq(diffRows, 3, "exactly three move rows differ")
eq(Fingerprint.movesGen3Of(lgM), Fingerprint.movesGen3Of(frM), "LeafGreen's rows are FireRed's")
local function dataOf(v) return { generation = 3, gen3Inputs = INPUTS[v] } end
eq(Fingerprint.coreGen3(dataOf("firered")), Fingerprint.coreGen3(dataOf("emerald")), "the non-move surface is identical")
check(Fingerprint.compute(dataOf("firered"), {}, 3) ~= Fingerprint.compute(dataOf("emerald"), {}, 3),
"the full fingerprints still differ")
print("[test] 2. the handshake pairs cross-family battles on host rules")
local function hello(v, linkType)
local s = { version = v, name = "P", trainerId = 1, gender = 0, dex = {}, store = { flags = {}, vars = {} } }
return Game3Link.hello({ version = v, data = dataOf(v), session = s, save = { player = { name = "P" } } }, linkType,
{ session = s })
end
local LT = Game3Link.LINKTYPE
local frH, emH, lgH = hello("firered", LT.SINGLE_BATTLE), hello("emerald", LT.SINGLE_BATTLE), hello("leafgreen", LT.SINGLE_BATTLE)
local function sane(h) return Wire.sanitize(Json.decode(Json.encode(h))) end
eq(sane(frH).game3.core, frH.game3.core, "Wire carries the core digest")
eq(sane(frH).game3.moves, frH.game3.moves, "Wire carries the moves digest")
local verdict, why, cross, hosted = Game3Link.decideOne(emH, sane(frH), LT.SINGLE_BATTLE)
eq(verdict, "full", "EM sees FR: a battle pairs (" .. tostring(why) .. ")")
eq(cross, true, "cross-family")
eq(hosted, true, "on host rules")
verdict, why, cross, hosted = Game3Link.decideOne(frH, sane(emH), LT.DOUBLE_BATTLE)
eq(verdict, "full", "FR sees EM: a double pairs (" .. tostring(why) .. ")")
eq(hosted, true, "on host rules")
verdict, why, cross, hosted = Game3Link.decideOne(frH, sane(lgH), LT.SINGLE_BATTLE)
eq(verdict, "full", "FR<->LG pairs as today")
eq(hosted, false, "FR<->LG needs no host rules")
eq(cross, false, "FR<->LG is one family")
local modded = sane(emH)
modded.linkModified = true
verdict, why = Game3Link.decideOne(frH, modded, LT.SINGLE_BATTLE)
eq(verdict, "refused", "a link-modifying mod still fails closed")
local otherCore = sane(emH)
otherCore.game3.core = ("0"):rep(16)
verdict, why = Game3Link.decideOne(frH, otherCore, LT.SINGLE_BATTLE)
eq(verdict, "refused", "a different species/ability/item surface is refused")
eq(why, "fingerprint_mismatch", "as a fingerprint mismatch")
print("[test] 3. the host rules block round-trips and is checked")
do
local block = { version = "firered", rules = Fingerprint.rulesGen3("firered"), moves = Fingerprint.movesGen3Of(frM), rows = {} }
local ids = {}
for id in pairs(frM) do ids[#ids + 1] = id end
table.sort(ids)
for _, id in ipairs(ids) do block.rows[#block.rows + 1] = HostRules.encodeRow(id, frM[id]) end
local wired = Wire.sanitize(Json.decode(Json.encode({ type = "game3_battle_setup", hostRules = block, party = {} })))
local decoded, err = HostRules.decode(wired.hostRules, frH.game3.moves)
check(decoded ~= nil, "a FireRed block decodes after Wire.sanitize: " .. tostring(err))
eq(decoded and decoded.rows[NATURE_POWER].accuracy, frM[NATURE_POWER].accuracy, "FireRed NATURE_POWER accuracy arrives")
local _, bad = HostRules.decode(wired.hostRules, emH.game3.moves)
eq(bad, "host_rules_rows", "rows that don't match the host hello are refused")
wired.hostRules.rules = Fingerprint.rulesGen3("emerald")
_, bad = HostRules.decode(wired.hostRules)
eq(bad, "host_rules_mismatch", "a rules digest that doesn't match the named game is refused")
end
print("[test] 4. cross-version link battles over the relay")
local H = require("tests.link3_harness")
H.SHARED["src.core.game3.battle.moves"] = nil
local ExtractScripts = require("src.import.gba.extract_scripts")
local Cache = require("tests.game3_cache")
H.bundle = ExtractScripts.loadBundle(Cache.cache(), ROOTS.emerald .. "/data/generated/gba", { allowIncomplete = true })
local function cacheFor(v)
local read = reader(ROOTS[v])
return { read = function(_, rel) return read(rel) end }
end
local function mon(species, moves)
return H.legal({ species = species, level = 50, moves = moves, personality = 0,
ivs = { hp = 20, atk = 20, def = 20, spe = 20, spa = 20, spd = 20 },
evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, item = 0 })
end
local function partyA()
return H.pack({ mon(65, { KINESIS, NATURE_POWER, SNATCH, PSYCHIC }), mon(143, { NATURE_POWER, KINESIS, SNATCH, BODY_SLAM }) })
end
local function partyB()
return H.pack({ mon(94, { KINESIS, NATURE_POWER, SNATCH, THUNDERBOLT }), mon(68, { SNATCH, NATURE_POWER, KINESIS, CROSS_CHOP }) })
end
local function moveNum(m)
if type(m) == "table" then m = m.id or m.move or m.num end
return tonumber(m)
end
local function observe(w, st)
local BP = require("src.core.game3.battle.profile")
local Moves = require("src.core.game3.battle.moves")
w.seen = w.seen or {
hostRules = st.hostRules,
family = BP.of(st).family,
rulesFrom = BP.of(st).rulesFrom,
focusPunch = BP.rule(st, "obedienceFocusPunchExempt"),
npAcc = Moves.get(NATURE_POWER).accuracy,
kinesisFlags = Moves.get(KINESIS).flags,
snatchFlags = Moves.get(SNATCH).flags,
}
end
local function usedMove(w, mon0, slot)
w.used = w.used or {}
local id = mon0.moves and moveNum(mon0.moves[slot])
if id then w.used[id] = true end
end
local function singles(w, st)
observe(w, st)
local m = st.player and st.player.mon or {}
local usable = {}
for i = 1, 4 do
if m.moves and m.moves[i] and (tonumber(m.pp and m.pp[i]) or 0) > 0 then usable[#usable + 1] = i end
end
local slot = usable[((st.turn or 0) % math.max(1, #usable)) + 1] or 1
usedMove(w, m, slot)
w.Ui._pendingCommand = w.Commands.playerAction(st, 1, slot)
end
local function doubles(w, st)
observe(w, st)
local sel = w.Battle._dblSel
if not (sel and sel.active) then return end
local id = sel.active
local b = w.State.battler(st, id)
local m = b and b.mon or {}
local usable = {}
for i = 1, 4 do
if m.moves and m.moves[i] and (tonumber(m.pp and m.pp[i]) or 0) > 0 then usable[#usable + 1] = i end
end
local slot = usable[(((st.turn or 0) + id) % math.max(1, #usable)) + 1] or 1
usedMove(w, m, slot)
local target = w.State.isPresent(st, 1) and 1 or 3
w.Ui._pendingCommand = w.Commands.playerAction(st, 1, slot, id, target)
end
local seq = 0
local function world(v, name, tid, gender)
seq = seq + 1
local w = H.newWorld(name .. seq, { name = name, trainerId = tid, gender = gender, party = {}, bag = {}, version = v })
w.version = v
w.game.version = v
w.game.data = { maps = {}, generation = 3, gen3Inputs = INPUTS[v] }
H.run(w, function()
require("src.core.GameVersion").set("emerald")
require("src.core.game3.song_ids").select("emerald")
local Moves = require("src.core.game3.battle.moves")
local load = Moves.loadRomPack
Moves.loadRomPack = function() return load(cacheFor(v)) end
Moves.loadRomPack()
end)
return w
end
local MIRROR = { win = "lose", lose = "win", draw = "draw" }
local function match(hostV, guestV, mode, seed)
local label = ("%s host vs %s guest (%s)"):format(hostV, guestV, mode)
print("[info] " .. label)
local relay = H.relay({ seed = seed, seats = 2, roomSeed = seed })
local w0 = world(hostV, "HOST", 0x1234, 0)
local w1 = world(guestV, "GUEST", 0x5678, 1)
local l0 = H.attachSeat(w0, relay, 0, { mode = mode, myParty = partyA(), profile = { rule = {} } })
local l1 = H.attachSeat(w1, relay, 1, { mode = mode, myParty = partyB(), profile = { rule = {} } })
local policy = mode == "double" and doubles or singles
local frames = 0
while frames < 30000 do
frames = frames + 1
H.step(w0, policy)
H.step(w1, policy)
relay:tick()
if w0.result and w1.result then break end
end
local same = hostV == guestV or FAMILY[hostV] == FAMILY[guestV]
and Fingerprint.movesGen3Of(INPUTS[hostV].moves) == Fingerprint.movesGen3Of(INPUTS[guestV].moves)
eq(H.run(w0, function() return l0.hostRules end), not same, label .. ": host link hostRules")
eq(H.run(w1, function() return l1.hostRules end), not same, label .. ": guest link hostRules")
local setupBytes, carried = 0, false
for _, row in ipairs(relay.log) do
if row.msg.type == "game3_battle_setup" then
setupBytes = math.max(setupBytes, #Json.encode(row.msg))
if row.seat == 0 and row.msg.hostRules then carried = true end
end
end
eq(carried, not same, label .. ": the host setup carries its rules block only when needed")
check(setupBytes < 32768, label .. ": setup fits the relay's 32768-byte cap (" .. setupBytes .. ")")
check(w0.result ~= nil and w1.result ~= nil, label .. ": both seats finished (" .. frames .. " frames)")
eq(MIRROR[w0.result], w1.result, label .. ": the results mirror (" .. tostring(w0.result) .. "/" .. tostring(w1.result) .. ")")
eq(H.run(w0, function() return w0.LB.endReason end), nil, label .. ": seat 0 saw no desync")
eq(H.run(w1, function() return w1.LB.endReason end), nil, label .. ": seat 1 saw no desync")
local h0 = H.run(w0, function() return w0.LB._myHashes end) or {}
local h1 = H.run(w1, function() return w1.LB._myHashes end) or {}
local n, ok = 0, true
for turn, v in pairs(h0) do
if h1[turn] ~= nil then
n = n + 1
if h1[turn] ~= v then ok = false end
end
end
check(ok and n >= 3, label .. ": every turn's digest matched across seats (" .. n .. " turns)")
local hostRow = INPUTS[hostV].moves
for _, w in ipairs({ w0, w1 }) do
local s = w.seen or {}
local who = (w == w0) and "host" or "guest"
eq(s.family, FAMILY[hostV], label .. ": " .. who .. " ran on the host's battle family")
eq(s.npAcc, hostRow[NATURE_POWER].accuracy, label .. ": " .. who .. " NATURE_POWER accuracy is the host's")
eq(s.kinesisFlags, hostRow[KINESIS].flags, label .. ": " .. who .. " KINESIS flags are the host's")
eq(s.snatchFlags, hostRow[SNATCH].flags, label .. ": " .. who .. " SNATCH flags are the host's")
eq(s.focusPunch, FAMILY[hostV] == "frlg", label .. ": " .. who .. " Focus Punch obedience rule is the host's")
end
eq(w1.seen and w1.seen.hostRules, (not same) and hostV or nil, label .. ": guest battle state names the host game")
for _, id in ipairs(DIFF) do
check((w0.used or {})[id] and (w1.used or {})[id], label .. ": both seats used move " .. id)
end
eq(H.run(w1, function() return require("src.core.game3.battle.moves").get(NATURE_POWER).accuracy end),
INPUTS[guestV].moves[NATURE_POWER].accuracy, label .. ": the guest's own rows are back after the battle")
end
match("firered", "emerald", "single", 0x4242)
match("emerald", "firered", "single", 0x1717)
match("firered", "emerald", "double", 0x5A5A)
match("leafgreen", "emerald", "single", 0x2323)
match("emerald", "leafgreen", "single", 0x3131)
match("firered", "leafgreen", "single", 0x6464)
print("[test] 5. the host rows decide the outcome (MAGIC_COAT vs KINESIS)")
local MAGIC_COAT, MIRROR_MOVE = 277, 119
local function coatMatch(sabotage)
local relay = H.relay({ seed = 0x7777, seats = 2, roomSeed = 0x7777 })
local w0 = world("firered", "HOST", 0x1234, 0)
local w1 = world("emerald", "GUEST", 0x5678, 1)
if sabotage then
H.run(w1, function()
w1.LB.applyHostRules = function()
require("src.core.game3.link.host_rules").clear()
return nil
end
end)
end
local a = H.pack({ mon(65, { KINESIS, KINESIS, KINESIS, KINESIS }) })
local b = H.pack({ mon(143, { MAGIC_COAT, MAGIC_COAT, MIRROR_MOVE, BODY_SLAM }) })
H.attachSeat(w0, relay, 0, { mode = "single", myParty = a, profile = { rule = {} } })
H.attachSeat(w1, relay, 1, { mode = "single", myParty = b, profile = { rule = {} } })
local frames = 0
while frames < 30000 do
frames = frames + 1
H.step(w0, singles)
H.step(w1, singles)
relay:tick()
if w0.result and w1.result then break end
end
local h0 = H.run(w0, function() return w0.LB._myHashes end) or {}
local h1 = H.run(w1, function() return w1.LB._myHashes end) or {}
local n, same = 0, true
for turn, v in pairs(h0) do
if h1[turn] ~= nil then
n = n + 1
if h1[turn] ~= v then same = false end
end
end
local reason = H.run(w1, function() return w1.LB.endReason end) or H.run(w0, function() return w0.LB.endReason end)
return same and reason == nil, n, reason
end
local okHost, nHost = coatMatch(false)
check(okHost and nHost >= 1, "on the host's rows both seats agree when KINESIS meets MAGIC_COAT (" .. nHost .. " turns)")
local okOwn, _, why = coatMatch(true)
check(not okOwn, "a guest that ignored the host rows would desync (" .. tostring(why) .. ")")
T.finish("emerald_link_xver")