-- NetLink: a link cable to a friend's GBA, through the internet. -- -- Load this in the window running "NetLink Cable" (NetLinkCable.gba): -- Tools > Scripting... then File > Load script... and pick this file. -- Your Pokemon game must be linked to that window (File > New multiplayer window). -- -- It carries the link-cable traffic between the NetLink Cable ROM (through a small -- block of its memory, the "mailbox") and the NetLink relay at remote-arc.site. -- The relay passes it to your partner's copy of this script. Nothing else is sent. -- -- You don't need to change anything in here. The host's NetLink Cable screen shows a -- 4-digit code; the guest presses B to join and types it in on theirs (arrow keys, then A). -- -- Saves: mGBA gives a game that is "player 2" its own save file (YourGame.sa2). When your game -- has to be player 2, this script copies YourGame.sav to YourGame.sa2 first and keeps the two -- in step afterwards (whichever one the game changes is copied to the other), so you keep -- playing on your usual save. Before it first overwrites YourGame.sav it keeps the old one as -- YourGame.sav.netlink-backup. It finds the save through mGBA's list of recent games. local CONFIG = { server = "remote-arc.site", port = 80, path = "/netlink/connect", token = "", -- set only in a personal invite copy; empty = use a code on screen } local VERSION = 1 CONFIG.server = os.getenv("NETLINK_SERVER") or CONFIG.server CONFIG.port = tonumber(os.getenv("NETLINK_PORT") or "") or CONFIG.port CONFIG.token = os.getenv("NETLINK_TOKEN") or CONFIG.token -- a pasted invite link works too: keep what follows /get/ CONFIG.token = (CONFIG.token:match("/get/([%w_%-]+)") or CONFIG.token):gsub("%s", "") -- ---- the mailbox (keep in step with rom/mailbox.h) ---- local MB = 0x02000000 local MAGIC = 0x4B4E4C4E local OFF = { version = 0x04, hwRole = 0x06, localState = 0x07, peerState = 0x08, netState = 0x09, textDirty = 0x0A, linkMode = 0x0B, heartbeat = 0x0C, inHead = 0x10, inTail = 0x12, outHead = 0x14, outTail = 0x16, transfers = 0x18, framesOut = 0x1C, framesIn = 0x20, session = 0x24, frameCount = 0x30, inFlush = 0x34, park = 0x35, inRing = 0x40, outRing = 0x840, text = 0x1040, } local RING, ROWS, COLS = 128, 18, 29 local ST_NONE, ST_WAITING, ST_LINKED = 0, 1, 2 local NET_CONNECTING, NET_ALONE, NET_PARTNER, NET_ERROR = 1, 2, 3, 4 local MODE_LINKED, MODE_DRAIN = 2, 3 local ROLE_UNKNOWN, ROLE_P1, ROLE_P2 = 0, 1, 2 local WHITE, ORANGE, RED, GREY, GREEN = "\1", "\2", "\3", "\4", "\5" local LOBBY = CONFIG.token == "" or CONFIG.token:find("@@", 1, true) ~= nil local KEY_A, KEY_B, KEY_START, KEY_RIGHT, KEY_LEFT, KEY_UP, KEY_DOWN = 0, 1, 3, 4, 5, 6, 7 local function r8(o) return emu:read8(MB + o) end local function r16(o) return emu:read16(MB + o) end local function r32(o) return emu:read32(MB + o) end local function w8(o, v) emu:write8(MB + o, v) end local function w16(o, v) emu:write16(MB + o, v % 0x10000) end local function w32(o, v) emu:write32(MB + o, v % 0x100000000) end -- ---- state ---- local S = { phase = "offline", -- offline, upgrading, online, failed sock = nil, rbuf = "", wbuf = "", frame = 0, retryAt = 0, backoff = 60, deadline = 0, pendingIn = {}, pinFirst = 1, pinLast = 0, peerState = ST_NONE, peerName = nil, peerRole = nil, -- link sessions: each side's cable counts the links it has made. Frames are tagged with the -- partner's session, so a new link never gets frames from an old one, and frames the partner -- sends for a new link before our game has re-linked are kept, not lost. peerSession = nil, rejectSession = nil, lastSession = -1, dropped = 0, myName = nil, roomName = nil, lastLocal = -1, lastRole = -1, lastStateSent = 0, rtt = nil, info = nil, err = nil, warned = false, shown = {}, sentFrames = 0, gotFrames = 0, inRoom = false, } local DEBUG = os.getenv("NETLINK_DEBUG") local function log(msg) console:log("[netlink] " .. msg) end local function dbg(msg) if not DEBUG then return end local f = io.open(DEBUG, "a") if f then f:write(string.format("%7d %s\n", S and S.frame or 0, msg)); f:close() end end -- code rooms (no invite): what we host or joined, so a reconnect gets back in local L = { pending = false, hostCode = nil, hostSecret = nil, joinCode = nil, joinSecret = nil, digits = {0, 0, 0, 0}, cursor = 1, keys = 0, held = 0, msg = nil, rejoinTries = 0, waitUntil = 0, want = nil, -- "host" or "join" once the player picks with B; nil = by window order } -- ---- saves: keep YourGame.sav and YourGame.sa2 in step (see the top of this file) ---- local WINDOWS = package.config:sub(1, 1) == "\\" local Sync = { active = false, base = nil, backedUp = false, conflict = false, needReset = nil, game = nil, sav = nil, sa2 = nil, err = nil, nextCheck = 0 } local function readFile(path) local f = io.open(path, "rb") if not f then return nil end local data = f:read("a") f:close() return data end local function writeFile(path, data, oldSize) local f = (oldSize == #data) and io.open(path, "r+b") or io.open(path, "wb") if not f then return false end local ok = f:write(data) f:close() return ok ~= nil end local function unquote(v) v = v:gsub("^%s+", ""):gsub("%s+$", "") if v:sub(1, 1) == '"' and v:sub(-1) == '"' then v = v:sub(2, -2) end v = v:gsub("\\x(%x%x%x%x)", function(h) return utf8.char(tonumber(h, 16)) end) return (v:gsub("\\(.)", "%1")) end local function readIni(path) local text = readFile(path) if not text then return nil end local ini, section = {}, "" for line in text:gmatch("[^\r\n]+") do local sec = line:match("^%[(.-)%]") if sec then section = sec ini[section] = ini[section] or {} else local k, v = line:match("^([^=]+)=(.*)$") if k then ini[section] = ini[section] or {} ini[section][k:gsub("%s+$", "")] = unquote(v) end end end return ini end -- mGBA's settings folder (where qt.ini and config.ini live) local function configDirs() local dirs = {} if WINDOWS then dirs[#dirs + 1] = "." -- a portable mGBA (portable.ini) if os.getenv("APPDATA") then dirs[#dirs + 1] = os.getenv("APPDATA") .. "/mGBA" end else local xdg = os.getenv("XDG_CONFIG_HOME") if xdg and xdg:sub(1, 1) == "/" then dirs[#dirs + 1] = xdg .. "/mgba" end if os.getenv("HOME") then dirs[#dirs + 1] = os.getenv("HOME") .. "/.config/mgba" end end return dirs end local function mgbaSettings() for _, dir in ipairs(configDirs()) do if dir ~= "." or readFile("portable.ini") then local qt = readIni(dir .. "/qt.ini") if qt then return qt, readIni(dir .. "/config.ini") or {} end end end return nil end local function isCable(path) return path:lower():match("([^/\\]+)$"):find("^netlinkcable") ~= nil end -- the recent-games list: {index = Ctrl+index in mGBA's File > Recent menu, path = ...} local function recent() local qt, cfg = mgbaSettings() local out = {} if not qt or not qt.mru then return out, cfg end for i = 0, 9 do local p = qt.mru[tostring(i)] if p and p ~= "" then out[#out + 1] = {index = i, path = p} end end return out, cfg end local function recentIndex(wantCable) for _, r in ipairs((recent())) do if isCable(r.path) == wantCable then return r.index, r.path end end return nil end function Sync.locate() local list, cfg = recent() for _, r in ipairs(list) do if not isCable(r.path) then local dir, file = r.path:match("^(.*)[/\\]([^/\\]+)$") if not dir then return nil end local saveDir = dir for _, sec in pairs(cfg or {}) do if sec.savegamePath and sec.savegamePath ~= "" then saveDir = sec.savegamePath end end local stem = file:gsub("%.[^.]*$", "") return r.path, saveDir .. "/" .. stem .. ".sav", saveDir .. "/" .. stem .. ".sa2", stem end end return nil end -- start keeping the saves in step; true if the game's player-2 save is ready -- a short fingerprint of a save, so we can tell later which file changed since the last copy function Sync.digest(data) local h = 2166136261 for i = 1, #data - 3, 4 do h = ((h ~ string.unpack(" 0 then writeFile(path, data) end end function Sync.activate(gameIsP2Now) if Sync.active then return true end if os.getenv("NETLINK_SAVE_SYNC") == "0" then -- tests: never touch real saves Sync.err = "Save copying is switched off (NETLINK_SAVE_SYNC=0)." return false end local rom, sav, sa2, stem = Sync.locate() if not rom then Sync.err = "Couldn't find your game in mGBA's recent games." return false end local main = readFile(sav) if not main or #main == 0 then Sync.err = "Couldn't read your save (" .. stem .. ".sav)." return false end Sync.game, Sync.sav, Sync.sa2 = stem, sav, sa2 local second = readFile(sa2) local last = (readFile(sav .. ".netlink") or ""):match("(%x+%-%d+)%s*$") if second == main then Sync.base = main elseif second and #second > 0 and last and Sync.digest(main) == last then -- YourGame.sav is as we left it, YourGame.sa2 has newer progress (e.g. a trade's save that -- mGBA was closed before we could copy): that goes to YourGame.sav backupOnce(sav .. ".netlink-backup", main) Sync.backedUp = true if not writeFile(sav, second, #main) then Sync.err = "Couldn't write " .. stem .. ".sav." return false end Sync.base = second log("copied your last NetLink progress from " .. stem .. ".sa2 to " .. stem .. ".sav") else -- YourGame.sav is the newer one (played on its own since), or .sa2 is someone else's: -- keep .sa2 aside, then give the game your usual save if second and #second > 0 and second ~= main then backupOnce(sa2 .. ".netlink-old", second) end if not writeFile(sa2, main, second and #second) then Sync.err = "Couldn't write " .. stem .. ".sa2." return false end Sync.base = main -- a game already running as player 2 read the old file when it started: a reset rereads it if gameIsP2Now then Sync.needReset = true end end Sync.remember(Sync.base) Sync.active, Sync.err = true, nil log("keeping " .. stem .. ".sav and " .. stem .. ".sa2 in step") return true end function Sync.tick(force) if not Sync.active or Sync.conflict then return end if not force and S.frame < Sync.nextCheck then return end Sync.nextCheck = S.frame + 60 -- never while the game is linking: the file work can stall this window long enough to break -- the link (mGBA runs the two windows in lockstep). A trade's save is copied once you've left. if not force and (r8(OFF.localState) ~= ST_NONE or r8(OFF.linkMode) ~= 0) then return end local main, second = readFile(Sync.sav), readFile(Sync.sa2) if not main or not second then return end local mainNew, secondNew = main ~= Sync.base, second ~= Sync.base if mainNew and not secondNew then if writeFile(Sync.sa2, main, #second) then Sync.base = main; Sync.remember(main) end elseif secondNew and not mainNew then if not Sync.backedUp then Sync.backedUp = writeFile(Sync.sav .. ".netlink-backup", main) end if writeFile(Sync.sav, second, #main) then Sync.base = second Sync.remember(second) log("saved your progress to " .. Sync.game .. ".sav") end elseif mainNew and secondNew then if main == second then Sync.base = main Sync.remember(main) else Sync.conflict = true Sync.err = Sync.game .. ".sav and .sa2 both changed: NetLink stopped copying. Keep the newer one." console:error("[netlink] " .. Sync.err) end end end local function queueIn(frame) S.pinLast = S.pinLast + 1 S.pendingIn[S.pinLast] = {f = frame, s = S.peerSession} end local function clearIn() S.pendingIn, S.pinFirst, S.pinLast = {}, 1, 0 end -- keep only the queued frames for which keep(session) is true local function filterIn(keep) local kept, n = {}, 0 for i = S.pinFirst, S.pinLast do local e = S.pendingIn[i] if keep(e.s) then kept[#kept + 1] = e else S.dropped = S.dropped + 1; n = n + 1 end end if n > 0 then dbg("filterIn dropped " .. n .. " kept " .. #kept .. " peerSession " .. tostring(S.peerSession)) end S.pendingIn, S.pinFirst, S.pinLast = kept, 1, #kept end local function send(line) if S.phase == "online" or S.phase == "upgrading" then S.wbuf = S.wbuf .. line .. "\n" end end local function disconnect(reason, retry) if S.sock then pcall(function() S.sock:close() end) end S.sock, S.rbuf, S.wbuf = nil, "", "" S.peerName, S.peerRole, S.peerState = nil, nil, ST_NONE S.inRoom, L.pending = false, false S.flip = nil clearIn() if retry == false then S.phase = "failed" else S.phase = "offline" S.retryAt = S.frame + S.backoff S.backoff = math.min(S.backoff * 2, 60 * 60) end S.err = reason log("disconnected: " .. tostring(reason)) end local function connect() S.err = nil log("connecting to " .. CONFIG.server .. ":" .. CONFIG.port) local sock, err = socket.connect(CONFIG.server, CONFIG.port) if not sock then disconnect("can't reach " .. CONFIG.server .. " (" .. tostring(err) .. ")") return end S.sock, S.phase, S.rbuf, S.wbuf = sock, "upgrading", "", "" S.deadline = S.frame + 60 * 10 local query = LOBBY and ("?v=" .. VERSION) or ("?token=" .. CONFIG.token .. "&v=" .. VERSION) S.wbuf = "GET " .. CONFIG.path .. query .. " HTTP/1.1\r\n" .. "Host: " .. CONFIG.server .. "\r\nUpgrade: arc-netlink\r\nConnection: Upgrade\r\n" .. "User-Agent: netlink.lua/" .. VERSION .. "\r\n\r\n" end local function handleLine(line) local cmd, rest = line:match("^(%S+)%s?(.*)$") if cmd == "F" then if #rest == 32 then local f = {} for i = 0, 7 do f[i + 1] = tonumber(rest:sub(i * 4 + 1, i * 4 + 4), 16) or 0 end if S.rejectSession ~= nil and S.peerSession == S.rejectSession then S.dropped = S.dropped + 1 -- the tail of a link our game has already left else queueIn(f) end S.gotFrames = S.gotFrames + 1 end elseif cmd == "S" then local st, sess = rest:match("^(%d+)%s*(%d*)") S.peerState = tonumber(st) or ST_NONE sess = tonumber(sess) if sess then -- the partner's link that is live right now (their cable says "linked" from its very first -- frame, and counts links: a new number is a new link, also after their cable restarts) S.peerLive = (S.peerState == ST_LINKED) and sess or nil end if sess and sess ~= S.peerSession then dbg("peer session " .. tostring(S.peerSession) .. " -> " .. sess .. " (state " .. S.peerState .. ")") -- the partner's cable started a new link: anything queued from before belongs to an old one S.peerSession = sess filterIn(function(t) return t == sess end) if S.rejectSession ~= sess then S.rejectSession = nil end end elseif cmd == "Q" then local sent = tonumber(rest) if sent then S.rtt = S.frame - sent end elseif cmd == "PEER" then local role, name = rest:match("^(%S+)%s?(.*)$") S.peerRole, S.peerName = role, (name ~= "" and name or "partner") S.peerState = ST_NONE S.peerSession, S.rejectSession, S.lastSession, S.peerLive = nil, nil, -1, nil clearIn() log("partner here: " .. S.peerName) elseif cmd == "NOPEER" then if S.peerName then log("partner left") end S.peerName, S.peerRole, S.peerState = nil, nil, ST_NONE clearIn() elseif cmd == "WELCOME" then local name, room = rest:match("^(%S+)%s?(.*)$") S.myName, S.roomName = name, room S.inRoom = true S.backoff = 60 log("in room " .. tostring(room) .. " as " .. tostring(name)) elseif cmd == "CODE" then L.hostCode, L.hostSecret = rest:match("^(%d+)%s+(%x+)$") L.pending, L.msg = false, nil log("hosting with code " .. tostring(L.hostCode)) elseif cmd == "JOINED" then L.joinCode, L.joinSecret = rest:match("^(%d+)%s+(%x+)$") L.pending, L.msg, L.rejoinTries = false, nil, 0 log("joined code " .. tostring(L.joinCode)) elseif cmd == "NOCODE" then L.pending = false if L.joinCode and rest == "unknown" and L.rejoinTries < 5 then L.rejoinTries = L.rejoinTries + 1 -- the host may not be back yet after a restart L.waitUntil = S.frame + 5 * 60 else L.joinCode, L.joinSecret = nil, nil L.msg = (rest == "full") and "Someone else already joined that code." or "No game with that code. Check it with your partner." end elseif cmd == "FLIP" then if rest == "cable" or rest == "wait" then if rest == "cable" and S.flip ~= "cable" then S.parked = false end S.flip = rest if rest == "cable" then Sync.activate(true) end else if S.flip == "cable" then w8(OFF.park, 0) end -- not needed after all: unpark S.flip = nil end elseif cmd == "MSG" then S.info = rest ~= "" and rest or nil if S.info then log(rest) end elseif cmd == "ERR" then disconnect(rest, false) end end local function pumpNetwork() if S.phase == "offline" then if S.frame >= S.retryAt then connect() end return end if S.phase == "failed" or not S.sock then return end -- read what has arrived while S.sock do local ok, has = pcall(function() return S.sock:hasdata() end) if not ok or not has then break end local data, err = S.sock:receive(4096) if not data then disconnect(err == "disconnected" and "the relay closed the connection" or tostring(err)) return end S.rbuf = S.rbuf .. data end if S.phase == "upgrading" then local stop = S.rbuf:find("\r\n\r\n", 1, true) if stop then local head = S.rbuf:sub(1, stop - 1) local body = S.rbuf:sub(stop + 4) local code = tonumber(head:match("^HTTP/%d%.%d (%d+)")) if code == 101 then S.phase, S.rbuf = "online", body S.lastLocal, S.lastRole = -1, -1 send("HELLO " .. VERSION) elseif code == 403 or code == 404 or code == 410 then disconnect("the relay refused this invite (" .. code .. ")" , false) return else disconnect("the relay answered " .. tostring(code or head:sub(1, 40))) return end elseif S.frame > S.deadline then disconnect("no answer from the relay") return end end if S.phase == "online" then while true do local nl = S.rbuf:find("\n", 1, true) if not nl then break end local line = S.rbuf:sub(1, nl - 1):gsub("\r$", "") S.rbuf = S.rbuf:sub(nl + 1) handleLine(line) if S.phase ~= "online" then return end end end if S.sock and #S.wbuf > 0 then local n, err = S.sock:send(S.wbuf) if not n then disconnect(tostring(err)) return end S.wbuf = S.wbuf:sub(n + 1) end end -- ---- code rooms: host (show a code) or join (type one in) ---- local function keyPressed(k) return (L.keys >> k) & 1 == 1 and ((L.prev >> k) & 1 == 0 or (L.held > 20 and L.held % 5 == 0)) end local function readKeys() L.prev = L.keys L.keys = emu:getKeys() L.held = (L.keys ~= 0 and L.keys == L.prev) and L.held + 1 or 0 end local function wantsToJoin(role) return L.want == "join" -- everyone hosts (shows a code) until they press B to join end local function lobbyTick() if not LOBBY or S.phase ~= "online" or Sync.needReset then return end local role = r8(OFF.hwRole) if S.inRoom then if S.myName == "Host" and not S.peerName then readKeys() if keyPressed(KEY_B) then -- join a friend instead L.want = "join" L.hostCode, L.hostSecret = nil, nil S.backoff = 30 disconnect("switching to joining") S.err = nil end end return end if L.pending or S.frame < L.waitUntil then return end if not wantsToJoin(role) then send("HOST" .. (L.hostCode and (" " .. L.hostCode .. " " .. L.hostSecret) or "")) L.pending = true elseif L.joinCode then send("JOIN " .. L.joinCode .. " " .. (L.joinSecret or "")) L.pending = true else -- the code entry screen: read this window's buttons readKeys() local d = L.digits if keyPressed(KEY_UP) then d[L.cursor] = (d[L.cursor] + 1) % 10; L.msg = nil end if keyPressed(KEY_DOWN) then d[L.cursor] = (d[L.cursor] + 9) % 10; L.msg = nil end if keyPressed(KEY_RIGHT) then L.cursor = L.cursor % 4 + 1 end if keyPressed(KEY_LEFT) then L.cursor = (L.cursor + 2) % 4 + 1 end if keyPressed(KEY_B) then L.want = "host" -- host after all elseif keyPressed(KEY_A) or keyPressed(KEY_START) then L.joinCode, L.joinSecret, L.rejoinTries = table.concat(d), nil, 0 send("JOIN " .. L.joinCode) L.pending = true end end end L.prev = 0 -- ROM -> relay: our game's frames and what it is doing local function pumpOut() if r8(OFF.inFlush) ~= 0 then -- our cable is starting a new link (it already emptied its own ring of the old one) w8(OFF.inFlush, 0) dbg("inFlush: peer " .. tostring(S.peerSession) .. " state " .. S.peerState .. " queued " .. (S.pinLast - S.pinFirst + 1)) if S.peerLive then filterIn(function(t) return t == S.peerLive end) -- the partner's new link: keep it S.rejectSession = nil else clearIn() -- the partner's old link: drop it S.rejectSession = S.peerSession end end local localState, role, session = r8(OFF.localState), r8(OFF.hwRole), r32(OFF.session) local mode = r8(OFF.linkMode) if mode == MODE_LINKED or mode == MODE_DRAIN then localState = ST_LINKED -- the cable counts a link as it starts, mid-frame: say so at once end if S.phase == "online" then if role ~= S.lastRole and S.flip ~= "cable" and not Sync.needReset then send("R " .. role) -- (parked, the cable reads "unknown": don't report that) S.lastRole = role end -- the state (with our link session) goes before this tick's frames, so the partner -- always knows which link a frame belongs to if localState ~= S.lastLocal or session ~= S.lastSession or S.frame - S.lastStateSent > 120 then send("S " .. localState .. " " .. session) S.lastLocal, S.lastSession, S.lastStateSent = localState, session, S.frame end if S.frame % 120 == 0 then send("P " .. S.frame) end end local tail, head = r16(OFF.outTail), r16(OFF.outHead) while tail ~= head do local base = OFF.outRing + (tail % RING) * 16 local words = {} for i = 0, 7 do words[i + 1] = r16(base + i * 2) end send(string.format("F %04x%04x%04x%04x%04x%04x%04x%04x", table.unpack(words))) S.sentFrames = S.sentFrames + 1 tail = (tail + 1) % 0x10000 end w16(OFF.outTail, tail) end -- relay -> ROM: the partner's frames local function pumpIn() local head, tail = r16(OFF.inHead), r16(OFF.inTail) -- frames go to the cable only while it is linked, and only the partner's link session that -- this link of ours is paired with: a newer one waits here for our next link, an older one -- is dropped. (Each side's game closes and reopens the link on its own schedule.) local mode = r8(OFF.linkMode) local open = mode == MODE_LINKED or mode == MODE_DRAIN local ours = r32(OFF.session) if ours ~= S.ourSession then -- our cable started a new link: pair it with the partner's. Everything queued so far is -- either their current link (keep it if they're linked) or older (drop it); only frames -- arriving from now on can belong to a newer link of theirs. dbg("our link " .. ours .. " starts; peer " .. tostring(S.peerSession) .. " state " .. S.peerState .. " queued " .. (S.pinLast - S.pinFirst + 1)) S.ourSession = ours S.boundOld = S.peerSession if S.peerLive then S.bound = S.peerLive filterIn(function(t) return t == S.bound end) else S.bound = nil S.dropped = S.dropped + (S.pinLast - S.pinFirst + 1) clearIn() end end while open and S.pinFirst <= S.pinLast and (head - tail) % 0x10000 < RING do local e = S.pendingIn[S.pinFirst] if S.bound == nil and e.s ~= S.boundOld then S.bound = e.s -- the partner's first new link after ours began end if e.s ~= S.bound then if e.s == S.boundOld then -- the end of a link we're no longer on S.pendingIn[S.pinFirst] = nil S.pinFirst = S.pinFirst + 1 S.dropped = S.dropped + 1 dbg("dropped an old frame (session " .. tostring(e.s) .. ", bound " .. tostring(S.bound) .. ")") goto continue end break -- a later link of theirs: wait for ours end S.pendingIn[S.pinFirst] = nil S.pinFirst = S.pinFirst + 1 do local base = OFF.inRing + (head % RING) * 16 for i = 0, 7 do w16(base + i * 2, e.f[i + 1]) end head = (head + 1) % 0x10000 end ::continue:: end w16(OFF.inHead, head) -- while someone still has a window to reopen, both games would be the same player: don't link w8(OFF.peerState, (S.peerName and not S.flip and not Sync.needReset) and S.peerState or ST_NONE) local net = NET_CONNECTING if S.phase == "failed" then net = NET_ERROR elseif S.phase == "online" then net = S.peerName and NET_PARTNER or NET_ALONE end w8(OFF.netState, net) end -- ---- the screen ---- local function wrap(text, width) local out, line = {}, "" for word in tostring(text):gmatch("%S+") do if #line == 0 then line = word elseif #line + 1 + #word <= width then line = line .. " " .. word else out[#out + 1] = line; line = word end end if #line > 0 then out[#out + 1] = line end return out end local GAME_STATE = { [ST_NONE] = "not linking", [ST_WAITING] = "waiting at the Cable Club", [ST_LINKED] = "linked", } -- what this player has to do so that exactly one game is player 1 (nil: nothing) local function lastStep(role) local mac = WINDOWS and "" or " (Cmd on a Mac)" if S.flip == "cable" then -- mGBA keeps a recent-games list per window, newest first: here that is NetLink Cable return "Click this window and press Ctrl+0" .. mac .. ". NetLink Cable reopens and your game becomes player 1." elseif Sync.needReset then return "Click your game window and press Ctrl+R" .. mac .. " (it restarts on your usual save), then click here and press A (the X key)." end return nil end local function compose() local rows = {} local function add(s) rows[#rows + 1] = s end local role = r8(OFF.hwRole) add(ORANGE .. "NETLINK CABLE") add(GREY .. (S.roomName and ("room: " .. S.roomName) or "a link cable to a friend")) add("") if Sync.needReset then -- the game started before its save was ready: everything else waits for this for _, l in ipairs(wrap("Your game started before NetLink had copied your save to it.", COLS)) do add(GREY .. l) end add("") add(ORANGE .. "One step:") for _, l in ipairs(wrap(lastStep(role), COLS)) do add(WHITE .. l) end while #rows < ROWS do add("") end return rows end if S.phase == "failed" then add(RED .. "Can't connect:") for _, l in ipairs(wrap(S.err or "?", COLS)) do add(RED .. l) end if S.err and S.err:find("refused", 1, true) then add("") for _, l in ipairs(wrap("The invite was deleted or replaced: ask for a new link.", COLS)) do add(GREY .. l) end end elseif S.phase == "online" and LOBBY and not S.inRoom and wantsToJoin(role) and not L.pending and not L.joinCode then add("Type in your partner's code") add(GREY .. "(it's on their NetLink Cable)") add("") local row = " " for i = 1, 4 do row = row .. (i == L.cursor and ORANGE .. "[" .. L.digits[i] .. "]" .. WHITE or " " .. L.digits[i] .. " ") .. " " end add(WHITE .. row) add("") if L.msg then for _, l in ipairs(wrap(L.msg, COLS)) do add(RED .. l) end; add("") end add(GREY .. "Up/Down: change the digit") add(GREY .. "Left/Right: next digit") add(GREY .. "A: connect B: host instead") add(GREY .. "Keyboard: arrows, X is A,") add(GREY .. "Z is B (click this window)") elseif S.phase ~= "online" or (LOBBY and not S.inRoom) then add(ORANGE .. (S.phase == "online" and (L.joinCode and "Joining " .. L.joinCode .. "..." or "Getting a code...") or "Connecting to the relay...")) if S.err then for _, l in ipairs(wrap(S.err, COLS)) do add(GREY .. l) end add(GREY .. "trying again in " .. math.max(0, (S.retryAt - S.frame) // 60) .. " s") end else add("You: " .. (S.myName or "?")) if S.peerName then local ms = S.rtt and string.format(" %d ms", math.floor(S.rtt * 1000 / 60 + 0.5)) or "" add("Partner: " .. S.peerName .. GREY .. ms) elseif LOBBY and S.myName == "Host" and L.hostCode then add("") add("Your code: " .. ORANGE .. L.hostCode:gsub(".", "%0 ")) add("") for _, l in ipairs(wrap("Tell your partner this code: they type it in on their NetLink Cable screen.", COLS)) do add(GREY .. l) end add(GREY .. "Joining a friend instead?") add(GREY .. "Press B (Z key) here.") else add(GREY .. "Partner: not connected yet") end add("") local step = lastStep(role) if step then add(ORANGE .. "One last step:") for _, l in ipairs(wrap(step, COLS)) do add(WHITE .. l) end end if Sync.conflict or (Sync.err and role == ROLE_P1) then for _, l in ipairs(wrap(Sync.err, COLS)) do add(RED .. l) end end if step then while #rows < ROWS - 1 do add("") end rows[ROWS] = "" return rows end add(ORANGE .. "Your game") local ls = r8(OFF.localState) add((ls == ST_LINKED and GREEN or WHITE) .. " " .. (GAME_STATE[ls] or "?")) if S.peerName then add(ORANGE .. "Their game") add((S.peerState == ST_LINKED and GREEN or WHITE) .. " " .. (GAME_STATE[S.peerState] or "?")) end add("") local hint if role == ROLE_UNKNOWN and not (LOBBY and S.myName == "Host") then hint = "Link your game to this window: File > New multiplayer window, game in one, NetLink Cable in the other." elseif S.info then hint = S.info elseif ls == ST_NONE and S.peerName then hint = "Go to a Pokemon Center 2F, talk to the right-hand attendant (Direct Corner) and choose the same as your partner." elseif ls == ST_WAITING and S.peerState ~= ST_WAITING and S.peerState ~= ST_LINKED then hint = "Waiting for your partner to talk to the attendant too." end if hint then for _, l in ipairs(wrap(hint, COLS)) do add(GREY .. l) end end end -- which end of the cable: the game in window 1 leads the link local roleLine if role == ROLE_P2 then roleLine = "Your game is player 1 (host)" elseif role == ROLE_P1 then roleLine = "Your game is player 2 (guest)" end while #rows < ROWS - 1 do add("") end rows[ROWS] = roleLine and (GREY .. roleLine) or "" return rows end local function drawText() local rows = compose() local changed = false for r = 1, ROWS do local s = (rows[r] or ""):sub(1, 32) if S.shown[r] ~= s then S.shown[r] = s changed = true local base = OFF.text + (r - 1) * 32 for i = 1, 32 do w8(base + i - 1, i <= #s and s:byte(i) or 0) end end end if changed then w8(OFF.textDirty, 1) end end -- ---- the frame callback ---- local function tick() S.frame = S.frame + 1 if emu:read32(MB) ~= MAGIC then if not S.warned and S.frame > 120 then S.warned = true console:error("[netlink] Load netlink.lua in the NetLink Cable window, not in your game's window.") end return end local romFrame = r32(OFF.frameCount) if romFrame < (S.romFrame or 0) then S.shown = {} end -- NetLink Cable restarted: redraw everything S.romFrame = romFrame w32(OFF.heartbeat, r32(OFF.heartbeat) + 1) local ok, err = pcall(function() pumpNetwork() lobbyTick() local role = r8(OFF.hwRole) -- parking: NetLink Cable leaves multiplayer mode while its window is reopened (Ctrl+0), or -- while the game is reset (Ctrl+R): mGBA 0.10 can hang or lose count of the players otherwise if S.flip == "cable" then if not S.parked then w8(OFF.park, 1) S.parked = true elseif r8(OFF.park) == 0 then S.flip, S.parked, S.lastRole = nil, false, -1 -- NetLink Cable restarted: reopened end elseif Sync.needReset then w8(OFF.park, 1) readKeys() if keyPressed(KEY_A) then Sync.needReset = nil w8(OFF.park, 0) end end if role == ROLE_P1 and not Sync.active and not Sync.tried then Sync.tried = true -- our game is player 2: it plays on YourGame.sa2 Sync.activate(true) end Sync.tick() pumpOut() pumpNetwork() -- send what pumpOut queued this frame pumpIn() if S.frame % 6 == 0 then drawText() end end) if not ok then console:error("[netlink] " .. tostring(err)) disconnect(tostring(err)) end end -- get YourGame.sa2 ready before the game is opened in the multiplayer window (it reads it at start) pcall(function() if emu:read32(MB) == MAGIC and r8(OFF.hwRole) ~= ROLE_P1 then Sync.activate(false) end end) if NETLINK_TEST_HOOK then NETLINK_TEST_HOOK(Sync, S) end -- tests only (lua/test_sync.py) if S.cb then callbacks:remove(S.cb) end S.cb = callbacks:add("frame", tick) -- one last copy when mGBA closes or the game stops for _, ev in ipairs({"shutdown", "stop"}) do pcall(function() callbacks:add(ev, function() pcall(Sync.tick, true) end) end) end log("loaded; relay " .. CONFIG.server .. ":" .. CONFIG.port .. (LOBBY and " (code mode)" or " (invite)"))