# Link play: threat model and what the code actually guarantees Link play is the only part of this game that reads bytes written by somebody else. This is what it defends against, what it does not, and where each guarantee lives. ## Where link play lives now Two separate things, one wire stack: - **In-game link is LAN only.** `src/link/LinkState.lua` is the START menu / Cable Club path over ENet on the local network. It has no relay, no room codes and no bracket: the in-game ONLINE and TOURNAMENT rows are gone and `src/link/Tournament.lua` no longer exists. - **Online play lives in the launcher**, over relay protocol v2 against `../pokeserver`. `src/online/Client.lua` holds one persistent TCP connection for the life of the process (`main.lua` pumps it every frame, whether the launcher or a booted game is on screen). Presence, rooms, spectators and tournaments are objects addressed inside that one connection. A tournament is a room of rooms: the relay creates each bracket match as an ordinary v2 room and attaches every non-playing entrant to it as a spectator. - A battle never runs in the launcher. The launcher **arena boots** the game (`src/online/ArenaBoot.lua` -> `Game:load` / `Game2:load` -> `src/ui/ArenaState.lua` or `src/ui/gen2/ArenaState.lua`), which skips splash, title and overworld, runs `LinkBattle` / `LinkBattle2` on the room session, and returns to the launcher with the room still selected. ## The arena profile The compatibility contract is one table, computed in the launcher without booting a game (`src/online/ArenaData.lua`), attached to every lobby entry and room, and checked twice: | Field | What it pins | | --- | --- | | `engine` | 1 or 2: which battle engine the room runs | | `version` | which generated cache boots (`red` ... `crystal`) | | `engineVersion`, `apiVersion` | exact string match, no skew | | `fingerprint` | `Fingerprint.compute` over the *arena* dataset | | `rulesetId` | the host-dealt rulebook (see below) | | `kind` | `vanilla` (all mods off) or `cart` (a sealed cart) | | `cart` | `{ id, version, hash }`, the `CartManifest.hash` of that cart | | `rule` | party size and level bounds, not part of the identity | **The relay enforces it on join.** `room_join` walks `PROFILE_MATCH_FIELDS` (`../pokeserver/relay.js:66`) in order -- `engine`, `version`, `engineVersion`, `fingerprint`, `rulesetId`, `kind`, `cart.hash` -- and refuses the first field that differs with `join_error { reason: "profile_mismatch", field, detail }`. `room_ready` runs the party against `rule` the same way and answers `party_ineligible`. Spectators are held to the profile too: a spectator runs the same engine to render the battle. **The client checks it before it will join or boot.** The ONLINE tab runs `ArenaData.equal` (everything except `rule`) against its own profile for every lobby row and greys out the ones it cannot play, with `ArenaData.describeMismatch` naming the first differing field so the reason is *what* differs rather than "incompatible" (`src/import/OnlinePanel.lua`). The battle it then boots is constructed with `strict = true` (`ArenaBoot.battleOpts`), so a mon that cannot be rebuilt from real species data refuses the battle instead of being approximated into a desync. A `vanilla` arena boots the mod loader in `disableAll` mode and a `cart` arena in `cartOnly` (`Loader.ARENA_MODES`, `src/mods/Loader.lua:618`). Neither reads or writes `options.safeMode` or `options.modsByVersion`, so an arena never disturbs the player's own enable state. `disableAll` keeps verified translations and only those: `_arenaDisableAll` keeps a mod that declares `language = true`, `affects_link = false` and no permissions, and `_arenaVerifyTranslations` then re-checks the survivors through `Handshake.onlineBlockers` and rolls back anything that does not qualify. `cartOnly` refuses to boot when the cart plan is not `enforced` or the slot's seal is broken; `sealed+` and `open` carts are refused outright, because a player-togglable pin set is not a fixed identity. ## Desync: the ruleset and the RNG counter Nearly every reported desync was turn 1 or 2 with **identical engine versions on both sides**. The cause was local: `BattleState` picked its ruleset from `game.save.options.ruleset`, an ordinary OPTIONS row, and `gen1_faithful` spends one RNG draw on the 1/256 miss that `modern_clean` does not. Both machines share one Park-Miller stream, so a single skipped draw offsets it permanently and the very next number is the damage factor. The fix is three parts, all of them in the wire: - The **host deals the ruleset**. `LinkBattle` takes `opts.ruleset` and overwrites `self.ruleset` from it the way it already overwrote `rng` (`src/link/LinkBattle.lua:275`, spectator at `:824`). In an arena that id comes from the profile. - The hello **carries** it (`Handshake.ruleset`, `src/link/Handshake.lua:180`) and `checkCompat` refuses a mismatch as `ruleset_skew`, so a LAN pairing cannot start split either. - The rulesets registry is part of the **Gen 1 fingerprint surface** (`src/link/Fingerprint.lua:319`), so a retuned rulebook is caught even when both sides name the same id. The other half of the diagnosis was that the state signature could not see a draw-count split, so a desync was blamed on the wrong turn. The RNG draw counter is now hashed into the `actives` component (`src/link/LinkBattle.lua:196`; Gen 2 stamps it on the battle at `src/link/LinkBattle2.lua:256` and hashes it in `src/battle/gen2/Battle.lua:5173`), so the two sides disagree on the exact turn the streams part. ## The boundary Everything a peer or the relay sends arrives as one JSON object per line. There is exactly one place it becomes a message: src/link/Net.lua reads bytes, frames lines, decodes JSON src/link/Wire.lua rebuilds each line as a typed message src/link/Session.lua the only path from a transport into a mode `Session:update` runs `Wire.sanitize` on every message before anything else sees it. A schema returns a **new** table holding only the fields it names, at the Lua types it names, so the rest of `src/link/` can read `msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be right by construction. A message with no schema (a mod's, or a future build's) keeps a bounded, scalar-only copy of its payload instead of being dropped. The v2 types go through the same door: `SCHEMAS.lobby_*`, `room_*` and `tour_*` live beside the v1 ones in `Wire.lua`, and `room_msg` sanitizes its inner lockstep message as well as its envelope, so `Client.roomSession()` hands `LinkBattle` the same shape a LAN `Session` does. A message that fails its schema is **dropped and logged**, never fatal. Latching a terminal failure would hand a hostile peer a cheaper disconnect than sending nothing at all. ### Why the bounds are loose Wire's numeric bounds are deliberately wider than the game's own clamps in `Protocol.unpackMon`. Both peers run identical clamps over identical packets; a bound that bit an honest value would change one side's copy of a mon and desync the lockstep. Wire's job is types and sizes. Rules are `Protocol`'s job, and it keeps its own clamps for the callers that reach it without a Session (the mod API, `tests/`). ### Containment behind it Assume something still gets through: - `Game:step` pcalls the link pump, and xpcalls `stack:update` **only while a link session is active** (`src/core/Game.lua:329`). A throw reaches `Game:breakLink` (`:276`), which closes the connection, unwinds to the overworld and says so. Transport failure and a caught Lua error are distinguished on purpose: the player is told "The link was broken." for one and "Something broke during the link." for the other, and the log line differs, so an engine bug inside a link battle no longer reads as a network fault. Outside link play the stack is unguarded on purpose: a blanket pcall would swallow real engine bugs and leave the game silently wrong instead of loudly broken. - `Net` caps a line at 256KB (`Net.MAX_LINE`) and its per-frame read at 512KB (`Net.MAX_RX_PER_FRAME`), so a peer that never sends a newline ends as a clean disconnect. - `Json.decode` refuses documents nested past 64 levels (`Json.MAX_DEPTH`), and takes an optional length cap that the link path passes and the mod-manifest path does not. ## The relay (`../pokeserver`) - A line that is not a JSON **object** with a string `type` is dropped before any handler runs, and `onLine` is wrapped in try/catch. `server.js` installs `uncaughtException`/`unhandledRejection` handlers: one bad packet must never take every live match down with the process. - Line buffers are capped, lines per second are capped, connections per IP and in total are capped, and an unbound connection that never hosts, joins or binds as a lobby is swept after 30s. - `SERVER_ONLY` (`relay.js:76`) is the set of message types the server is the only legitimate author of. It covers the v1 names (`peer_gone`, `bracket_update`, `match_start`, `tournament_over`, `spectate`, ...) and every v2 one (`lobby_welcome`, `lobby_list`, `lobby_delta`, `room_state`, `room_replay`, `room_deadline`, `room_result`, `room_closed`, `tour_state`, `tour_match`, `tour_match_spectate`, `tour_bye`, `tour_deadline`, `tour_over`, `tour_closed`). A peer that sends one has it dropped rather than forwarded, so a bracket opponent cannot forge a result or fake "your opponent left". - `room_msg` only carries an inner `msg.type` from a fixed set (`hello`, `party`, `action`, `hash`, `replace`, `bye`, `forfeit`), only from a seated player, and only while the room is `battling`. Its `seq` must rise, so a resumed client's replayed tail is idempotent. - Names, notes, room codes and profile strings are reduced to a printable subset and capped on the way in (10 characters for a trainer name, 16 for a lobby display name, 40 for an advertisement note), because they are rendered by the dashboard and broadcast to every participant. `pokeserver/test/hostile.js` is the regression net for all of that. ### Identity: tickets Going online verified takes a ticket, not a credential. `POST /lobby/ticket` on the HTTPS port (`lobby.js`) authenticates with the usual sync headers, mints 32 hex characters bound to `{ account, displayName }`, valid for 60 seconds and **single use**, and the client presents that on the plaintext relay in `lobby_hello`. The relay redeems it in process (both modules live in `server.js`) and never sees the device token: nothing on port 7778 can be replayed into an account. The ticket store is memory only, so a restart invalidates every outstanding ticket and clients simply mint another. A client with no ticket still connects and still plays, as a guest, listed `verified: false`. ### Heartbeat, resume and the replay bound - `ping`/`pong` runs both ways. The relay pings a connection idle for 20s and drops one idle for 60s, on top of TCP keepalive; the client's own heartbeat interval arrives in `lobby_welcome`. - A dropped socket keeps its seat for **2 minutes**. A new connection sends `resume { session, ack }` and gets its room, its match and the traffic it missed back. `room_replay` also names `yourSeq`, the highest `seq` the relay logged from that client, so the client re-sends only its own lost tail. - The room's message log is bounded at **512 messages or 256 KiB**, whichever comes first, since the last `room_ready` pair. A spectator that arrives while the log is intact gets `room_replay` and then live traffic; one that arrives after the bound already dropped messages is refused with `spectate_late` rather than being fed a battle it cannot reconstruct. ## What is NOT defended **Party legality is trust-the-client.** Online rooms, spectating and tournaments live in the launcher, where play meets strangers, and `Handshake.onlineAllowed` is a Lua function in the same VM the mods load into. It cannot be made tamper-proof in-process, and pretending otherwise would only cost honest mod authors. What lockstep and `Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that a cheater cannot invent stats, moves, or a shiny: every derived value is rebuilt locally from real species data. The relay checks party size and level bounds against the room's `rule`, and that is the only rule it enforces. A player can still send a legal party they farmed or edited. That is the honest boundary. **Match results are trust-the-client too**, and this is where the relay stopped being silent. Both sides report; agreement resolves the match, a lone report waits out a grace window, a lone forfeit resolves at once, and a stalled match resolves on its deadline. Two **disagreeing** reports are counted as a dispute (`totals.disputes` and the room's or tournament's own counter, both in the dashboard snapshot), logged with both sides' reports verbatim, and settled **deterministically**, never by coin flip: a forfeit or a gone player decides it, otherwise the earliest report wins (`roomTiebreak`, `relay.js:1761`). The outcome carries `how` -- `reported`, `agreed`, `forfeit`, `timeout`, `disputed`, `disconnect` or `closed` -- so a tournament organizer can see which matches were argued. The same is true of the arena profile. A patched client can lie about its fingerprint or its cart hash; what it cannot do is lie without the relay having a record of it, since the relay sees every profile and compares the two sides of every room. A sealed cart's hash proves the manifest, the pins' sha256 prove the archives, and the fingerprint proves the merged data -- against an honest client. Client-side attestation is deliberately not built. This is an open-source Lua game: it would be theater, and it would break honest mods. **The relay has no TLS.** Port 7778 is plaintext, so party contents, trades and trainer names are visible to anyone on the network path. Tickets keep account credentials off it (above), and there is nothing secret in a Pokemon party, but it is a real property of the system and not an oversight. Fixing it means a TLS terminator in front of the relay and a client that speaks it, which is a version break for every shipped build. **Presence is not durable.** The lobby, every room and every outstanding ticket live in the relay process. A restart clears them and clients reconnect and re-advertise within one heartbeat. Nothing about a match is persisted, so nothing about a match can be audited after the fact beyond the log lines. **The dashboard has no default password.** `DASHBOARD_PASSWORD` is required; with it unset the relay runs and the dashboard simply does not start. It is still Basic Auth over plain HTTP, so it belongs behind an IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`). ## Tests luajit tests/link_hostile.lua v1 message types x every wrong type luajit tests/link_desync_fuzz.lua fuzz: baseline, mutation, ruleset split luajit tests/run_link_tests.lua the above, plus online_client and link2_* cd ../pokeserver && npm test relay smoke, brackets, lobby, tour What guards which claim: - **Ruleset desync**: `run_link_tests.lua` asserts the draw-count difference between `gen1_faithful` and `modern_clean` directly, then runs a loopback battle where the guest's own `modern_clean` OPTIONS row is overridden by the host's dealt ruleset. `link_desync_fuzz.lua`'s ruleset-split mode deliberately configures the two sides differently and fails if the two `LinkBattle`s ever end up holding different rulebooks. - **Wire hostility**: `link_hostile.lua` builds a corpus from a template per v1 message type, replaces each field (and several nested ones) with every wrong Lua type, and drives the survivors through the real trade session, a real lockstep battle and a real spectator battle, **including their draws** -- because the two nastiest payloads are delayed-fuse ones that crash on render rather than on receipt. The v2 envelopes are covered on the client side by `tests/online_client.lua`, which sanitizes oversized `lobby_list`, `room_replay`, `room_state` and `tour_state` payloads and asserts a stream of malformed server messages is counted and dropped without throwing or changing connection state. - **Client, rooms, resume, tournaments**: `tests/online_client.lua` (run from `run_link_tests.lua`) covers the handshake, lobby, advertising, rooms, `match_start`, the room session, reconnect and resume replay, spectating, results, a full tournament run and the lockstep path, against a fake relay and, when a socket library is present, a real one. - **Gen 2 lockstep**: `tests/link2_lockstep.lua` and `tests/link2_desync_fuzz.lua`. - **Relay**: `../pokeserver/test/lobby.js` (bind, presence, rooms, spectating, the lockstep stream, deadlines, resume, a real HTTP ticket from a real sync account, and a v1 room in the same process), `test/tour.js` (a 5-player v2 bracket with byes, child rooms, spectator fan-out, the server-side shot clock, disconnects and creator handoff), `test/hostile.js`, plus `test/smoke.js`, `test/keepalive.js`, `test/tournament16.js` and `test/tournament5.js` for the v1 path. - **End to end**: the LÖVE drivers. `tests/drivers/online_relay_smoke.lua` runs two `Client`s through a real pokeserver over TCP: room create, join, ready, a full Gen 1 `LinkBattle` over `roomSession()`, both reports and the relay's `room_result`. `tests/drivers/online_tour_smoke.lua` runs five clients through a v2 tournament including a bye, an outside spectator, a mid-battle socket kill with resume and replay, and a forfeit. `tests/drivers/arena_boot_loopback.lua` and `arena_boot_gen2_loopback.lua` (with their `*_spec.lua` payloads) boot straight into an arena battle, play it against a headless guest and screenshot the battle and the launcher it returns to.