#!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path local failed = 0 local function check(cond, msg) if cond then print("[ok] " .. msg) else failed = failed + 1 print("[FAIL] " .. msg) end end local function eq(a, b, msg) check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) end local COUNTER_MAP = "FR_TEST_CABLE_COUNTER" local LINK_ROOM_MAP = "FR_TEST_LINK_ROOM" local LOBBY_MAP = "FR_TEST_LOBBY" local MAPS = { [COUNTER_MAP] = { warps = { { x = 1, y = 6, destMap = LOBBY_MAP, destWarp = 1 }, { x = 9, y = 1, destMap = LINK_ROOM_MAP, destWarp = 1 }, }, }, [LINK_ROOM_MAP] = { warps = { { x = 5, y = 8, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 }, }, }, [LOBBY_MAP] = { warps = { { x = 7, y = 8, destMap = COUNTER_MAP, destWarp = 1 } } }, } local store = { flags = {}, vars = {} } local session = { store = store, map = COUNTER_MAP, x = 9, y = 1, party = {}, bag = { pockets = { items = { { id = 4, qty = 3 } } } }, } local game = { data = { maps = MAPS }, session = session, saveGame = function() return true end } package.loaded["src.core.game3.runtime"] = { getSession = function() return session end, isActive = function() return true end, _game = game, _mod = nil, } local Player = { cellX = 9, cellY = 1, facing = "up" } package.loaded["src.core.game3.player"] = Player local mapLoads = {} package.loaded["src.core.game3.map"] = { load = function(_mod, _game, mapId, opts) mapLoads[#mapLoads + 1] = { map = mapId, x = opts and opts.x, y = opts and opts.y } end, current = nil, } local ctx = { specialVars = {} } local adapterCalls = { warp = {}, se = {} } local adapters = { log = function() end, playSe = function(id) adapterCalls.se[#adapterCalls.se + 1] = id end, warp = function(group, num, warpId, x, y, done) adapterCalls.warp[#adapterCalls.warp + 1] = { group = group, num = num, warpId = warpId, x = x, y = y } if done then done() end end, } local romBundle = require("tests.game3_cache").bundle() if not romBundle then package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end, ascii = function(key) return key end, has = function() return true end, ir = function(key) return { { t = "text", s = key } } end, key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, count = function() return 0 end, list = function() return {} end, lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, } end package.loaded["src.core.game3.scripting.space"] = { store = store, mapId = COUNTER_MAP, vm = { ctx = ctx, adapters = adapters }, ensureBundle = function() return romBundle end, } local Space = package.loaded["src.core.game3.scripting.space"] local Natives = require("src.core.game3.scripting.natives") local NativesLink = require("src.core.game3.scripting.natives_link") local Link = require("src.core.game3.link") local Game3Link = require("src.link.Game3Link") local FakeRelay = require("tests.g3link_fake_relay") local Versions = require("src.import.gba.versions") local Flags = require("src.core.game3.scripting.flags") local SaveMenu = require("src.ui.game3.save_menu") local function result() return tonumber(Flags.getVar(store, ctx, Link.VAR_RESULT)) or 0 end print("[test] 1. ids match the 0-based def_special index of pokefirered/data/specials.inc") local EXPECTED = { SetCableClubWarp = 0x01, DoCableClubWarp = 0x02, ReturnFromLinkRoom = 0x03, CleanupLinkRoomState = 0x04, ExitLinkRoom = 0x05, CloseLink = 0x1F, CableClub_AskSaveTheGame = 0x23, Field_AskSaveTheGame = 0x5D, LoadPlayerBag = 0x14B, IsWirelessAdapterConnected = 0x16A, } local Std = require("src.core.game3.scripting.stdscripts") for name, id in pairs(EXPECTED) do eq(NativesLink.SPECIAL[name], id, "NativesLink.SPECIAL." .. name) check(Natives.ALLOW["special:" .. id] ~= nil, name .. " is bound in Natives.ALLOW") eq(Std.SPECIAL_NAME_BY_ID[id], name, "special 0x" .. string.format("%X", id) .. " answers to its pret name") end local discovered = {} for _, n in ipairs(Natives.MODULE_NAMES) do discovered[n] = true end check(discovered.natives_link, "natives_link is discovered as a natives module") local known = false for _, n in ipairs(Natives.KNOWN_MODULES) do if n == "natives_link" then known = true end end check(known, "natives_link is in the headless KNOWN_MODULES fallback") print("[test] 2. every link special dispatches instead of logging as unknown") Natives.resetLog() local logs = {} local quiet = { log = function(m) logs[#logs + 1] = m end } local probeIds = {} for id in pairs(NativesLink.HANDLERS) do probeIds[#probeIds + 1] = id end table.sort(probeIds) for _, id in ipairs(probeIds) do if id ~= NativesLink.SPECIAL.Field_AskSaveTheGame and id ~= NativesLink.SPECIAL.CableClub_AskSaveTheGame then local _, _, isKnown = Natives.special({ specialVars = {} }, id, quiet) check(isKnown, string.format("special 0x%X dispatches to a handler", id)) end end eq(#logs, 0, "nothing reached the unknown-special log") print("[test] 3. IsWirelessAdapterConnected answers FALSE") Flags.setVar(store, ctx, Link.VAR_RESULT, 9) local yield, value = Natives.special(ctx, NativesLink.SPECIAL.IsWirelessAdapterConnected, adapters) eq(yield, false, "IsWirelessAdapterConnected does not yield") eq(value, 0, "IsWirelessAdapterConnected returns FALSE for specialvar") eq(result(), 0, "VAR_RESULT is FALSE") print("[test] 4. SetCableClubWarp records the return warp at a link room door") session.dynamicWarp = nil session.warpDestination = nil Player.cellX, Player.cellY = 9, 1 Space.mapId = COUNTER_MAP Natives.special(ctx, NativesLink.SPECIAL.SetCableClubWarp, adapters) eq(session.warpDestination and session.warpDestination.map, LINK_ROOM_MAP, "warp destination is the link room behind the door") check(type(session.dynamicWarp) == "table", "a dynamic return warp was recorded") eq(session.dynamicWarp and session.dynamicWarp.map, COUNTER_MAP, "it returns to the counter map") eq(session.dynamicWarp and session.dynamicWarp.warpId, 1, "it names the door warp (0-based)") eq(session.dynamicWarp and session.dynamicWarp.x, 9, "it remembers the door x") eq(session.dynamicWarp and session.dynamicWarp.y, 1, "it remembers the door y") session.dynamicWarp = nil Player.cellX, Player.cellY = 1, 6 Natives.special(ctx, NativesLink.SPECIAL.SetCableClubWarp, adapters) eq(session.warpDestination and session.warpDestination.map, LOBBY_MAP, "an ordinary door still sets the warp destination") eq(session.dynamicWarp, nil, "an ordinary door records no dynamic return warp") print("[test] 5. DoCableClubWarp replays a setwarp destination, not a warp already taken") Player.cellX, Player.cellY = 9, 1 Space.mapId = COUNTER_MAP adapterCalls.warp, adapterCalls.se = {}, {} Natives.special(ctx, NativesLink.SPECIAL.SetCableClubWarp, adapters) session.warpDestination = { mapGroup = 12, mapNum = 1, warpId = 0xFF, x = 5, y = 8 } Natives.special(ctx, NativesLink.SPECIAL.DoCableClubWarp, adapters) eq(#adapterCalls.warp, 1, "the stored destination was warped to") eq(adapterCalls.warp[1] and adapterCalls.warp[1].group, 12, "with the stored map group") eq(adapterCalls.warp[1] and adapterCalls.warp[1].y, 8, "with the stored y") eq(adapterCalls.se[1], 9, "SE_EXIT played") adapterCalls.warp = {} Natives.special(ctx, NativesLink.SPECIAL.SetCableClubWarp, adapters) Space.mapId = LINK_ROOM_MAP Natives.special(ctx, NativesLink.SPECIAL.DoCableClubWarp, adapters) eq(#adapterCalls.warp, 0, "a script warp that already changed the map is not repeated") Space.mapId = COUNTER_MAP print("[test] 6. Field_AskSaveTheGame gates entry on the save prompt") Flags.setVar(store, ctx, Link.VAR_RESULT, 7) local yielded = Natives.special(ctx, NativesLink.SPECIAL.Field_AskSaveTheGame, adapters) check(yielded, "the script yields while the save prompt is up") check(SaveMenu.isOpen(), "the save menu opened") check(ctx.nativePoll and ctx.nativePoll() == false, "the script stays parked while it is open") SaveMenu.cursor = 2 SaveMenu.confirm() eq(result(), 0, "answering NO reports FALSE, which aborts the link") check(ctx.nativePoll and ctx.nativePoll() == true, "the script resumes once it closes") Flags.setVar(store, ctx, Link.VAR_RESULT, 7) Natives.special(ctx, NativesLink.SPECIAL.Field_AskSaveTheGame, adapters) SaveMenu.cursor = 1 SaveMenu.confirm() SaveMenu.confirm() SaveMenu.confirm() eq(result(), 1, "saving reports TRUE, which lets the link continue") check(not SaveMenu.isOpen(), "the save menu closed") print("[test] 7. two FireRed peers pair over the relay, anything else is refused") local host, guest, room = FakeRelay.pair({ game = game }) host:update(0) guest:update(0) check(host:isReady(), "the host reached ready") check(guest:isReady(), "the guest reached ready") eq(host._transport.relay, true, "over the relay transport") eq(#host:players(), 2, "the host lists both players") eq(host.myHello.generation, 3, "the hello announces generation 3") eq(host.myHello.game3.cacheVersion, Versions.CACHE_VERSION, "the hello carries the cache version") local r7 = FakeRelay.room({ seats = 2 }) local lone = Game3Link.attach(FakeRelay.transport(r7, 0), { game = game }) local Handshake = require("src.link.Handshake") r7:session(1):send(Handshake.hello(game, "battle")) lone:update(0) check(not lone:isOpen(), "a peer with no FireRed hello is refused") eq(lone.reason, "peer_is_not_firered", "and says why") local r7b = FakeRelay.room({ seats = 2 }) local strict = Game3Link.attach(FakeRelay.transport(r7b, 0), { game = game }) local staleHello = Game3Link.hello(game, Game3Link.LINKTYPE.BATTLE) staleHello.game3.cacheVersion = Versions.CACHE_VERSION + 1 r7b:session(1):send(staleHello) strict:update(0) check(not strict:isOpen(), "a peer built on another cache is refused") eq(strict.reason, "cache_version_mismatch", "and says why") local Handshake = require("src.link.Handshake") local realRuleset = Handshake.ruleset Handshake.ruleset = function() return "gen1_modern" end local r7c = FakeRelay.room({ seats = 2 }) local mixed = Game3Link.attach(FakeRelay.transport(r7c, 0), { game = game }) local otherHello = Game3Link.hello(game, Game3Link.LINKTYPE.BATTLE) Handshake.ruleset = realRuleset eq(otherHello.ruleset, Handshake.DEFAULT_RULESET, "the Gen 1 RULESET option never reaches a FireRed hello") r7c:session(1):send(otherHello) mixed:update(0) check(mixed:isOpen() and mixed:isReady(), "so two players with different Gen 1 rulesets still link") print("[test] 8. CloseLink tears the session down and the peer hears it") Link.reset() Link.attach(host) eq(Link.link, host, "the cable club session owns the link") Natives.special(ctx, NativesLink.SPECIAL.CloseLink, adapters) check(not host:isOpen(), "CloseLink closed the local end") eq(Link.link, nil, "and cleared the cable club session") guest:update(0) check(not guest:isOpen(), "the peer saw the goodbye") eq(guest.reason, "peer_left", "and reports the peer leaving") eq(#room.players, 1, "CloseLink also left the relay room") eq(room.players[1] and room.players[1].seat, 1, "only the peer is still seated") print("[test] 9. a peer drop in a link room returns the player to the counter") Link.reset() local host2, guest2, room2 = FakeRelay.pair({ game = game }) host2:update(0) guest2:update(0) Link.attach(host2) Space.mapId = LINK_ROOM_MAP session.map = LINK_ROOM_MAP session.dynamicWarp = { map = COUNTER_MAP, warpId = 1, x = 9, y = 1 } session.warpDestination = nil Flags.setVar(store, ctx, Link.VAR_CABLE_CLUB_STATE, Link.USING.UNION_ROOM) Flags.setVar(store, ctx, Link.VAR_0x8004, Link.USING.UNION_ROOM) mapLoads = {} room2:drop(1) Link.update(0) check(not host2:isOpen(), "the survivor closed its end") eq(host2.reason, "peer_dropped", "the drop is reported as a peer drop") eq(#mapLoads, 1, "the player was warped back") eq(mapLoads[1] and mapLoads[1].map, COUNTER_MAP, "to the cable club counter map") eq(mapLoads[1] and mapLoads[1].x, 9, "onto the link room door x") eq(mapLoads[1] and mapLoads[1].y, 1, "onto the link room door y") -- pokefirered/data/scripts/cable_club.inc:106 the 2F's OnFrame exit script clears it check(tonumber(Flags.getVar(store, ctx, Link.VAR_CABLE_CLUB_STATE)) ~= 0, "VAR_CABLE_CLUB_STATE stays set for the Pokemon Center's exit script") print("[test] 10. CleanupLinkRoomState restores the bag and asks for the saved party") Link.reset() session.map = LINK_ROOM_MAP local walkedInWith = session.bag.pockets.items[1].id Link.loadPlayerBag() session.bag = { pockets = { items = { { id = 13, qty = 1 } } } } local asked = 0 local prev = Natives.ALLOW["special:40"] Natives.ALLOW["special:40"] = function() asked = asked + 1 end Flags.setVar(store, ctx, Link.VAR_0x8004, Link.USING.SINGLE_BATTLE) session.dynamicWarp = { map = COUNTER_MAP, warpId = 1, x = 9, y = 1 } session.warpDestination = nil Natives.special(ctx, NativesLink.SPECIAL.CleanupLinkRoomState, adapters) eq(asked, 1, "LoadPlayerParty (special 0x28) was asked for the pre-link party") eq(session.bag.pockets.items[1].id, walkedInWith, "the bag the player walked in with is back") eq(session.warpDestination and session.warpDestination.map, COUNTER_MAP, "the warp destination became the dynamic return warp") asked = 0 Link.loadPlayerBag() Flags.setVar(store, ctx, Link.VAR_0x8004, Link.USING.TRADE_CENTER) Natives.special(ctx, NativesLink.SPECIAL.CleanupLinkRoomState, adapters) eq(asked, 0, "a trade room does not restore the battle party") Natives.ALLOW["special:40"] = prev print("[test] 11. ExitLinkRoom with no peer left walks the player out on its own") Link.reset() Space.mapId = LINK_ROOM_MAP Flags.setVar(store, ctx, Link.VAR_CABLE_CLUB_STATE, Link.USING.TRADE_CENTER) Flags.setVar(store, ctx, Link.VAR_0x8004, Link.USING.TRADE_CENTER) session.dynamicWarp = { map = COUNTER_MAP, warpId = 1, x = 9, y = 1 } session.warpDestination = nil mapLoads = {} Natives.special(ctx, NativesLink.SPECIAL.ExitLinkRoom, adapters) eq(#mapLoads, 1, "the player left the link room") eq(mapLoads[1] and mapLoads[1].map, COUNTER_MAP, "back to the counter") -- pokefirered/data/scripts/cable_club.inc:106 the 2F's OnFrame exit script clears it check(tonumber(Flags.getVar(store, ctx, Link.VAR_CABLE_CLUB_STATE)) ~= 0, "VAR_CABLE_CLUB_STATE stays set for the Pokemon Center's exit script") print("[test] 12. the real Pokemon Center 2F door is a dynamic warp back to the counter") local Cache = require("tests.game3_cache") local root = Cache.mount("meta.json") if not root then print("[skip] cable club door check: " .. tostring(Cache.reason)) else local Dataset = require("src.core.game3.dataset") local realMaps = Dataset.buildMaps() local CENTER = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" game.data.maps = realMaps Space.mapId = CENTER session.map = CENTER session.dynamicWarp = nil session.warpDestination = nil local door = nil for _, w in ipairs((realMaps[CENTER] and realMaps[CENTER].warps) or {}) do if w.destMap == "FR_TRADE_CENTER" then door = w end end check(door ~= nil, "the 2F trade centre door is in the map header") if door then Player.cellX, Player.cellY = tonumber(door.x), tonumber(door.y) Natives.special(ctx, NativesLink.SPECIAL.SetCableClubWarp, adapters) eq(session.warpDestination and session.warpDestination.map, "FR_TRADE_CENTER", "SetCableClubWarp aims at the trade centre") eq(session.dynamicWarp and session.dynamicWarp.map, CENTER, "and records the return warp to the Pokemon Center 2F") local mapId, x, y = Link.resolveDest(session.dynamicWarp) eq(mapId, CENTER, "the return warp resolves to the counter map") eq(x, tonumber(door.x), "at the door x") eq(y, tonumber(door.y), "at the door y") end game.data.maps = MAPS end print("[test] the trainer cards the two machines swap, and the special that shows one") Link.reset() session.name = "RED" session.trainerId = 0x1234 local cardHost, cardGuest = FakeRelay.pair({ game = game }) cardHost:update(0) cardGuest:update(0) Link.attach(cardHost) Link.update(0) cardGuest:update(0) -- pokefirered/src/union_room.c:1863 CreateTrainerCardInBuffer local outgoing = cardGuest:take(Link.MSG.CARD) check(outgoing ~= nil, "this machine's trainer card goes out once the link is ready") eq(outgoing and outgoing.card and outgoing.card.name, session.name, "with the player's name") eq(outgoing and outgoing.card and outgoing.card.trainerId, session.trainerId, "and the player's trainer id") cardGuest:send({ type = Link.MSG.CARD, card = { name = "BLUE", trainerId = 0x2222 } }) cardHost:update(0) Link.update(0) eq(Link.peerCard and Link.peerCard.name, "BLUE", "and the peer's card arrives the same way") eq(Link.peerCards[1] and Link.peerCards[1].name, "BLUE", "filed under the sender's seat") eq(NativesLink.SPECIAL.Script_ShowLinkTrainerCard, 0x2A, "Script_ShowLinkTrainerCard is def_special 0x2A") local cardLogs = {} local _, _, known = Natives.special({ specialVars = {} }, 0x2A, { log = function(m) cardLogs[#cardLogs + 1] = m end }) check(known, "and it dispatches to a handler instead of logging an unknown special") eq(#cardLogs, 0, "nothing reached the unknown-special log") print("[test] the counter opens the relay transport, never a LAN socket") Link.reset() -- pokefirered/src/link.c:386 OpenLink local openRoom = FakeRelay.room({ seats = 2 }) local opened = Link.open({ transport = FakeRelay.transport(openRoom, 1), game = game }) check(opened ~= nil, "Link.open adopts a relay transport and attaches it") eq(Link.link, opened, "and it becomes the live session") eq(opened.seat, 1, "seated from the transport") Link.reset() local bad, why = Link.open({ address = "203.0.113.1:1", game = game }) eq(bad, nil, "an address opens nothing: there is no LAN for Gen 3") eq(why, "no_transport", "and says why") eq(Link.dial, nil, "Link.dial is gone") Link.reset() eq(Link.beginConnect({}), false, "with no link there is no connect screen, so the counter just keeps waiting") print("[test] ExitLinkRoom counts each seat once and seat-less exits one by one") local function stubLink(nseats, queue) local s = { nseats = nseats, seat = 0, open = true, queue = queue } function s:update() end function s:isReady() return false end function s:isOpen() return self.open end function s:send() end function s:close() self.open = false end function s:take(kind) if kind ~= "game3_exit_link_room" then return nil end return table.remove(self.queue, 1) end return s end local function queueExit(lk) Link.reset() Space.mapId = LINK_ROOM_MAP session.map = LINK_ROOM_MAP session.dynamicWarp = { map = COUNTER_MAP, warpId = 1, x = 9, y = 1 } session.warpDestination = nil mapLoads = {} Link.link = lk Link.exitQueued = true end local anon = stubLink(3, { {}, {} }) queueExit(anon) Link.update(0) eq(#mapLoads, 1, "two seat-less exits fill a 3-seat room") check(not anon.open, "and the link is closed") local dup = stubLink(3, { { seat = 1 }, { seat = 1 } }) queueExit(dup) Link.update(0) eq(#mapLoads, 0, "the same seat twice is one exit") dup.queue[1] = { seat = 2 } Link.update(0) eq(#mapLoads, 1, "the second seat completes it") print("[test] a 4-seat link card is the seat's own, never the last one received") Link.reset() local shown package.loaded["src.ui.game3.trainer_card"] = { show = function(o) shown = o.session end } local hadLove = love love = { graphics = {} } Link.link = stubLink(4, {}) Link.peerCards = { [1] = { name = "BLUE" } } Link.peerCard = { name = "BLUE" } Flags.setVar(store, ctx, Link.VAR_0x8006, 2) Link.showLinkTrainerCard(ctx, adapters) check(shown ~= nil and shown.name ~= "BLUE", "seat 2's card has not arrived: not BLUE's") Link.link = stubLink(2, {}) Link.peerCards = {} Link.peerCard = { name = "BLUE" } Flags.setVar(store, ctx, Link.VAR_0x8006, 1) Link.showLinkTrainerCard(ctx, adapters) eq(shown and shown.name, "BLUE", "a 2-seat link still falls back to the peer's card") love = hadLove package.loaded["src.ui.game3.trainer_card"] = nil Link.link = nil print("[test] upgrade_required shows the Connect text verbatim") local hadConnect = package.loaded["src.online.Connect"] package.loaded["src.online.Connect"] = { upgradeText = function() return "Please update." end } eq(Link.reasonText("upgrade_required"), "Please update.", "the whole upgrade line, period kept") package.loaded["src.online.Connect"] = { upgradeText = function() return nil end } check(Link.reasonText("timeout"):find("timeout", 1, true) ~= nil, "other reasons still name the cause") package.loaded["src.online.Connect"] = hadConnect Link.reset() if failed == 0 then print("[pass] link session") os.exit(0) end print("[fail] link session: " .. failed) os.exit(1)