-- Link protocol helpers: Pokémon serialization and the trade session -- state machine (pure logic, headless-testable). -- -- Message types exchanged after pairing: -- {type="hello", ...} handshake v2 (src/link/Handshake.lua) -- {type="records", pokemon=, moves=} subset trade: per-record hashes -- {type="party", mons=[...]} party (both directions) -- {type="pick", index} trade: chosen slot in the sent list -- {type="confirm", ok=bool} trade: final yes/no -- {type="action", ...} battle: guest -> host choice -- {type="event", ...} battle: host -> guest display event -- {type="bye"} local Fingerprint = require("src.link.Fingerprint") local Handshake = require("src.link.Handshake") local Runtime = require("src.mods.Runtime") local Protocol = {} Protocol.hello = Handshake.hello Protocol.checkCompat = Handshake.checkCompat -- the extra bag is JSON-safe by contract, the same restriction the save -- serializer enforces; anything else is dropped rather than trusted local function plainCopy(value, depth) if type(value) ~= "table" then return nil end if (depth or 0) > 8 then return nil end local out = {} for k, v in pairs(value) do local kt, vt = type(k), type(v) if kt == "string" or kt == "number" then if vt == "string" or vt == "number" or vt == "boolean" then out[k] = v elseif vt == "table" then out[k] = plainCopy(v, (depth or 0) + 1) end end end return out end Protocol.plainCopy = plainCopy -- serialize a mon instance for the wire (plain data only). ppUps rides -- along because the real cable transmitted it and its absence silently -- capped a PP-Upped move at base PP on the receiving side. ot/otId ride -- along too: pokered's trade sends the whole party block including each -- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT), -- and the receiver keeps them verbatim -- a differing OT/ID is what marks a -- mon as traded (boosted EXP, high-level disobedience). Omitting them made -- a received mon show the receiver as its OT (#215). function Protocol.packMon(mon) local moves = {} for _, mv in ipairs(mon.moves) do table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps }) end return { species = mon.species, level = mon.level, exp = mon.exp, hp = mon.hp, status = mon.status, nickname = mon.nickname, dvs = mon.dvs, statExp = mon.statExp, moves = moves, ot = mon.ot, otId = mon.otId, extra = plainCopy(mon.extra), } end -- rebuild a mon locally (recomputes stats from real species data so a -- tampered packet can't invent stats). opts.strict is set once two v2 -- peers have agreed on a verdict: a mon that cannot be rebuilt identically -- is rejected by name instead of quietly mutated into something else. function Protocol.unpackMon(data, packed, opts) local Stats = require("src.pokemon.Stats") local Growth = require("src.pokemon.Growth") local strict = opts and opts.strict -- forceLevel comes from an "auto-level" ruling. The picker's ANY choice -- ("use each mon's real level", Gen1's only mode) is a string sentinel on -- the LinkState/Tournament side (see levelForWire) that must mean "no -- forced level" here. Coerce once so a non-numeric level string -- the ANY -- sentinel, an old peer, or a mod (#204) -- can never reach math.floor -- below: tonumber("ANY") == nil, i.e. keep the packed real level, while -- tonumber(50)/tonumber("50") both give 50. local forceLevel = opts and tonumber(opts.forceLevel) or nil local def = data.pokemon[packed.species] if not def then if strict then return nil, "unknown POKéMON" end return nil end local level = math.max(2, math.min(100, math.floor(packed.level or 5))) -- "auto-level" tournaments/matches: every participant's real level is -- ignored and everyone rebuilds at the same fixed level instead, so a -- Lv12 and a Lv100 party can battle on equal footing. Both sides pass -- the identical forceLevel for a given match, so this stays symmetric. if forceLevel then level = math.max(2, math.min(100, math.floor(forceLevel))) end local dvs = {} for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0))) end local statExp = {} for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0))) end local stats = Stats.calc(def, level, dvs, statExp) local moves = {} for _, mv in ipairs(packed.moves or {}) do local mdef = data.moves[mv.id] if mdef and #moves < 4 then local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5) local entry = { id = mv.id, pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) } if mv.ppUps ~= nil then entry.ppUps = ppUps end table.insert(moves, entry) end end if #moves == 0 then -- the v1 path keeps the substitute verbatim for peers built against it; -- a negotiated v2 link says so out loud instead if strict then return nil, "no shared moves" end moves = { { id = "TACKLE", pp = 35 } } end -- a forced level scales every stat including max HP, so the real party's -- current HP/status (a different level's numbers, possibly mid-fight) -- isn't meaningful anymore -- auto-level starts everyone full and fresh, -- same as a standardized tournament format would local forced = forceLevel local hp = forced and stats.hp or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))) local status = forced and nil or packed.status -- preserve the sender's original-trainer identity (party_struct MON_OTID + -- wPartyMonOT on a real cable), clamped/typed like every other field so a -- tampered packet can't inject a bad ID or a huge name. Left nil when the -- packet omits them (a v1/old peer) -- no worse than before for that legacy -- path, and once ot is set the load-time stampOT backfill (mon.ot or ...) -- becomes a no-op so the sender's identity survives save/reload (#215). local otId = packed.otId and math.max(0, math.min(65535, math.floor(packed.otId))) or nil local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil return { species = packed.species, level = level, exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))), dvs = dvs, statExp = statExp, stats = stats, hp = hp, status = status, nickname = packed.nickname, ot = ot, otId = otId, moves = moves, -- a namespace whose mod this install lacks survives untouched, so the -- mon keeps it for the trip home extra = plainCopy(packed.extra), } end function Protocol.packParty(party, indices) local mons = {} if indices then for _, i in ipairs(indices) do table.insert(mons, Protocol.packMon(party[i])) end return mons end for _, mon in ipairs(party) do table.insert(mons, Protocol.packMon(mon)) end return mons end -- ------- subset negotiation -- every record hash, not just this party's slice: the receiver filters -- its OWN party against these (eligibleParty), so a message limited to the -- sender's party read "not on the other game" for every species the two -- parties did not happen to share, and a subset trade between different -- games showed neither side (#511). The full catalog is ~300 short hash -- strings -- still one message. function Protocol.recordsMessage(data, party) return { type = "records", pokemon = Fingerprint.records(data, "pokemon"), moves = Fingerprint.records(data, "moves") } end -- a mon may cross the wire only if both peers rebuild it identically: the -- species and every move id has to exist on the other game with the same -- record hash. Filtering is symmetric, so the two sides always agree on -- which slots are in play and a pick can never land on a different mon. function Protocol.eligibleParty(party, myRecords, theirRecords) local eligible, reasons = {}, {} theirRecords = theirRecords or {} local theirSpecies = theirRecords.pokemon or {} local theirMoves = theirRecords.moves or {} local mySpecies = (myRecords or {}).pokemon or {} local myMoves = (myRecords or {}).moves or {} for i, mon in ipairs(party or {}) do local reason if not theirSpecies[mon.species] then reason = "not on the other game" elseif theirSpecies[mon.species] ~= mySpecies[mon.species] then reason = "different data" else for _, mv in ipairs(mon.moves or {}) do if not theirMoves[mv.id] then reason = "unknown move" break elseif theirMoves[mv.id] ~= myMoves[mv.id] then reason = "different move data" break end end end eligible[i] = reason == nil reasons[i] = reason end return eligible, reasons end -- ------------------------------------------------------------------- -- Trade session: symmetric state machine. Feed it messages; read -- .stage ("waitRecords" -> "waitParty" -> "picking" -> "waitPick" -> -- "confirming" -> "done"/"cancelled"). When done, .result = -- {give=idx, getMon=mon}. A subset session negotiates the eligible -- slots first, so both sides send and index the same filtered list. -- ------------------------------------------------------------------- local TradeSession = {} TradeSession.__index = TradeSession Protocol.TradeSession = TradeSession -- opts: { subset, strict, peerName } -- all absent on the v1 path function TradeSession.new(data, party, opts) opts = opts or {} local self = setmetatable({ data = data, party = party, subset = opts.subset or false, strict = opts.strict or false, peerName = opts.peerName, stage = opts.subset and "waitRecords" or "waitParty", sendIndices = nil, eligible = nil, reasons = {}, theirParty = nil, myPick = nil, theirPick = nil, myConfirm = nil, theirConfirm = nil, }, TradeSession) if not self.subset then local all = {} for i = 1, #party do all[i] = i end self.sendIndices = all end return self end -- the first message on the wire: a subset trade has to agree on which mons -- both games rebuild identically before either party can be sent function TradeSession:opening() if self.subset then return Protocol.recordsMessage(self.data, self.party) end return self:partyMessage() end function TradeSession:partyMessage() return { type = "party", mons = Protocol.packParty(self.party, self.sendIndices) } end function TradeSession:_negotiate(theirRecords) local mine = { pokemon = Fingerprint.records(self.data, "pokemon"), moves = Fingerprint.records(self.data, "moves") } self.eligible, self.reasons = Protocol.eligibleParty(self.party, mine, theirRecords) local indices = {} for i = 1, #self.party do if self.eligible[i] then indices[#indices + 1] = i end end self.sendIndices = indices end -- the UI greys what the other game would rebuild differently function TradeSession:canPick(index) return self.eligible == nil or self.eligible[index] == true end -- returns a message to put on the wire, or nil function TradeSession:handle(msg) if msg.type == "records" then -- only once: re-filtering after our party went out would slide the -- indices the peer is already holding if self.stage ~= "waitRecords" then return nil end self:_negotiate(msg) self.stage = "waitParty" return self:partyMessage() elseif msg.type == "party" then self.theirParty = {} for _, packed in ipairs(msg.mons or {}) do local mon, why = Protocol.unpackMon(self.data, packed, { strict = self.strict }) if mon then table.insert(self.theirParty, mon) elseif self.strict then -- dropping a row would slide every later index by one and the -- two sides would commit different mons; refuse the whole trade self.stage = "cancelled" self.error = why or "the other game sent an unknown POKéMON" return nil end end if self.stage == "waitParty" then self.stage = "picking" end elseif msg.type == "pick" then self.theirPick = msg.index self:advance() elseif msg.type == "confirm" then self.theirConfirm = msg.ok self:advance() elseif msg.type == "bye" then self.stage = "cancelled" end return nil end -- index is a real party slot; the wire carries its position in the list -- this side actually sent, which is the only space both peers share function TradeSession:wireIndex(index) for pos, i in ipairs(self.sendIndices or {}) do if i == index then return pos end end return index end function TradeSession:pick(index) self.myPick = index self:advance() return { type = "pick", index = self:wireIndex(index) } end function TradeSession:confirm(ok) self.myConfirm = ok self:advance() return { type = "confirm", ok = ok } end function TradeSession:advance() if self.stage == "picking" and self.myPick then self.stage = self.theirPick and "confirming" or "waitPick" elseif self.stage == "waitPick" and self.theirPick then self.stage = "confirming" end if self.stage == "confirming" and self.myConfirm ~= nil and self.theirConfirm ~= nil then if self.myConfirm and self.theirConfirm then self.stage = "done" else self.stage = "cancelled" end end end -- apply the completed trade to the local party; returns the new mon -- (trade evolutions like Kadabra -> Alakazam trigger on the receiving -- side, as on a real link cable) function TradeSession:apply(game) assert(self.stage == "done", "trade not complete") local received = self.theirParty[self.theirPick] local sent = self.party[self.myPick] received.traded = true -- boosted exp (different OT) -- a mod validates its own extra namespace here, before the mon is filed Runtime.emit("pokemon.received", { mon = received, from = "link", peerName = self.peerName }) self.party[self.myPick] = received -- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the -- companion away is the biggest happiness hit and zeroes the mood if game and sent then require("src.world.PikachuFollower") .modifyHappiness(game.save, "TRADE", sent) end if game and game.save.pokedex then game.save.pokedex.seen[received.species] = true game.save.pokedex.owned[received.species] = true end local def = self.data.pokemon[received.species] local evolveTo for _, evo in ipairs(def.evolutions or {}) do if evo.method == "TRADE" then evolveTo = evo.species break end end Runtime.emit("trade.completed", { sent = sent, received = received, evolveTo = evolveTo }) return received, evolveTo end return Protocol