-- Lockstep desync fuzz: two full LinkBattle simulations over a loopback, -- driven the way a player drives them, checked turn by turn. -- -- The suite in run_link_tests.lua battles one Charizard against one -- Blastoise with the A button held, which reaches exactly one code path: -- move slot 1, no switch, no faint, no status, no locked action. Every -- desync players actually hit lived outside it. This walks the rest: -- random multi-mon parties with random movesets, random move choices, -- switches, faints and replacements, and -- the part that matters -- two -- sides that are NOT identical clients. Three things differ between two -- real players and never differ inside one test process: -- -- options animations on/off, text speed, battle style change how long a -- side spends in its message queue -- speed the GAME SPEED option multiplies the logic clock, so one side -- takes several fixed steps per frame while the other takes one -- lag the relay is not instantaneous, so a peer's action lands while -- this side is somewhere in the middle of its own turn -- -- None of those may change the outcome: a lockstep battle is decided by the -- two actions and the shared RNG stream, nothing else. What they DID change -- was when each machine wrote per-turn bookkeeping flags, and the state hash -- read `false`/`0` against `nil` as a divergence and ended winnable matches -- as draws (LinkBattle's `off`, BattleState's per-battler flinch clear, and -- fightLockedAction no longer storing its boundTurns mirror). -- -- Self-contained; run directly or via run_link_tests.lua: -- luajit tests/link_desync_fuzz.lua [runs] [firstSeed] package.path = "./?.lua;./?/init.lua;" .. package.path love = love or require("tests.love_stub") local Data = require("src.core.Data") if not Data.pokemon then Data:load() end local Pokemon = require("src.pokemon.Pokemon") local Protocol = require("src.link.Protocol") local Net = require("src.link.Net") local Json = require("src.link.Json") local Input = require("src.core.Input") local LinkBattle = require("src.link.LinkBattle") Input:init() require("src.render.Font").load(Data) -- one PRNG per run so a failure replays from its seed alone local function makeRandom(seed) local s = seed % 2147483647 if s <= 0 then s = s + 2147483646 end return function(a, b) s = (s * 16807) % 2147483647 if a == nil then return s / 2147483647 end if b == nil then a, b = 1, a end return a + (s % (b - a + 1)) end end local SPECIES = {} for id in pairs(Data.pokemon) do SPECIES[#SPECIES + 1] = id end table.sort(SPECIES) -- pairs order is not stable; the seed has to be the only input local MOVES = {} for id, m in pairs(Data.moves) do if id ~= "STRUGGLE" and m.pp and m.pp > 0 then MOVES[#MOVES + 1] = id end end table.sort(MOVES) -- a loopback pair that holds each message for `delay` pumps before it lands -- in the peer's inbox, so one side is mid-queue when the other's action -- arrives (Net.loopbackPair on its own delivers instantly, which is the one -- thing the real relay never does) local function laggyPair(delayA, delayB) local a, b = Net.loopbackPair() a.wire, b.wire = {}, {} a.delay, b.delay = delayA or 0, delayB or 0 local function send(self, msg) if self.closed then return end local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end end local function update(self) for i = #self.wire, 1, -1 do local row = self.wire[i] row.at = row.at - 1 if row.at <= 0 then table.remove(self.wire, i) if not self.peerEnd.closed then table.insert(self.peerEnd.inbox, row.msg) end end end end a.send, b.send = send, send a.update, b.update = update, update return a, b end local function makeFakeGame(name, options) local save = require("src.core.SaveData").newGame() save.player.name = name for k, v in pairs(options or {}) do save.options[k] = v end local stack = { list = {} } function stack:push(s, ...) table.insert(self.list, s) if s.enter then s:enter(...) end end function stack:pop() table.remove(self.list) end function stack:top() return self.list[#self.list] end function stack:update(dt) local t = self:top() if t and t.update then t:update(dt) end end return { data = Data, input = Input, stack = stack, save = save } end -- random movesets, not level-up ones: status, trapping, thrash, bide, -- Hyper Beam and Rage are where the locked-action paths live local function randomParty(rnd, size) local party = {} for _ = 1, size do local mon = Pokemon.new(Data, SPECIES[rnd(1, #SPECIES)], rnd(5, 60)) mon.moves = {} for _ = 1, rnd(1, 4) do local id = MOVES[rnd(1, #MOVES)] table.insert(mon.moves, { id = id, pp = Data.moves[id].pp }) end party[#party + 1] = mon end return party end local PARTS = { "actives", "volatile", "bench" } local function firstMismatch(a, b) local turns = {} for t in pairs(a.localParts) do turns[t] = true end for t in pairs(b.localParts) do turns[t] = true end local ordered = {} for t in pairs(turns) do ordered[#ordered + 1] = t end table.sort(ordered) for _, t in ipairs(ordered) do local mine, theirs = a.localParts[t], b.localParts[t] if mine and theirs then for _, part in ipairs(PARTS) do if mine[part] ~= theirs[part] then return t, part end end end end end -- Returns nil when the run agreed, or a description of how it split. local function runOne(seed) local rnd = makeRandom(seed) -- the two sides are deliberately different clients local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" } local optsB = { animations = true, textSpeed = 3, battleStyle = "SHIFT" } local stepsA, stepsB = rnd(1, 4), 1 -- A fast-forwards, B does not local lagA, lagB = rnd(0, 8), rnd(0, 8) local gameA, gameB = makeFakeGame("RED", optsA), makeFakeGame("BLUE", optsB) gameA.save.party = randomParty(rnd, rnd(1, 4)) gameB.save.party = randomParty(rnd, rnd(1, 4)) local netA, netB = laggyPair(lagA, lagB) local battleSeed = rnd(1, 2 ^ 30) local battleA = LinkBattle.newHost(gameA, netA, { myParty = Protocol.packParty(gameA.save.party), theirParty = Protocol.packParty(gameB.save.party), theirName = "BLUE", seed = battleSeed }) local battleB = LinkBattle.newGuest(gameB, netB, { myParty = Protocol.packParty(gameB.save.party), theirParty = Protocol.packParty(gameA.save.party), theirName = "RED", seed = battleSeed }) if not battleA or not battleB then return nil, 0 end local resA, resB battleA.onFinish = function(r) resA = r end battleB.onFinish = function(r) resB = r end gameA.stack:push(battleA) gameB.stack:push(battleB) local sides = { { bt = battleA, game = gameA, party = battleA.playerParty, steps = stepsA }, { bt = battleB, game = gameB, party = battleB.playerParty, steps = stepsB }, } -- Only the cursor is steered; A (held below) does the committing, so the -- battle is reached through DisplayBattleMenu exactly as a player reaches -- it and every locked action (recharge / thrash / rage / bide / trapping / -- bound / Struggle) fires from its own code path rather than being -- injected. A switch waits for a second menu frame for the same reason: -- the real PKMN branch sits behind the menu-entry bookkeeping. local function drive(side) local bt = side.bt if bt.result then return end if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase == "moveSelect" then local usable = {} for i, mv in ipairs(bt.player.curMoves) do if (mv.pp or 0) > 0 and bt.player.disabledSlot ~= i then usable[#usable + 1] = i end end if #usable > 0 then bt.moveIndex = usable[rnd(1, #usable)] end bt.menuIndex = 1 elseif bt.phase == "menu" then bt.menuIndex = 1 -- FIGHT side.menuFrames = (side.menuFrames or 0) + 1 if side.menuFrames >= 2 and not bt:menuLockedAction(bt.player) and rnd(1, 100) <= 10 then for _ = 1, 4 do local mon = side.party[rnd(1, #side.party)] if mon.hp > 0 and mon ~= bt.player.mon then bt:resolveSwitch(mon) side.menuFrames = 0 return end end end end end local guard = 0 while (resA == nil or resB == nil) and guard < 60000 do guard = guard + 1 Input.pressed = { a = true } for _, side in ipairs(sides) do for _ = 1, side.steps do drive(side) side.game.stack:update(1 / 60) end end local turn, part = firstMismatch(battleA, battleB) if turn then return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format( seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0 end end -- a battle still running at the guard is a stalemate (two mons that cannot -- KO each other), not a split; only a finished one can be checked mirrored if guard < 60000 and (battleA.player.mon.hp ~= battleB.enemy.mon.hp or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format( seed, battleA.player.mon.hp, battleA.enemy.mon.hp, battleB.enemy.mon.hp, battleB.player.mon.hp), battleA.turnCount or 0 end return nil, battleA.turnCount or 0 end local RUNS = tonumber(arg and arg[1]) or 40 local FIRST = tonumber(arg and arg[2]) or 1 local failures, turns = 0, 0 for seed = FIRST, FIRST + RUNS - 1 do local ok, why, t = pcall(runOne, seed) turns = turns + (t or 0) if not ok then failures = failures + 1 print("FAIL link desync fuzz seed " .. seed .. ": " .. tostring(why)) elseif why then failures = failures + 1 print("FAIL link desync fuzz " .. why) end end print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures)) assert(failures == 0, failures .. " lockstep run(s) diverged") return true