Files
2026-09-24 05:17:32 -04:00

18 KiB

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.

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 link 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:168) is the set of message types the server is the only legitimate author of. It covers 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, trade_commit, trade_abort, the invite, plaza and group ones, ...). 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 the engine's allowlist (engine 1/2: hello, party, action, hash, replace, bye, forfeit, records, pick, confirm, trade_confirm; engine 3: the game3_* set, each with a byte cap), 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 ids 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 LinkBattles 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 Clients 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.