This commit is contained in:
bryanthaboi
2026-08-17 22:52:33 -04:00
parent 4e1ab1879b
commit 7b1e796c48
14 changed files with 1124 additions and 90 deletions
+54 -4
View File
@@ -66,13 +66,39 @@ table.sort(MOVES)
-- 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)
-- mutation mode: a peer whose messages arrive with a random field (or the
-- type itself) at the wrong Lua type. What is delivered is what Session
-- would deliver -- Wire.sanitize's output, or nothing at all -- so a run
-- exercises the real receive path rather than a hand-written stand-in.
local Wire = require("src.link.Wire")
local HOSTILE = { {}, { 1, 2, 3 }, 0, -1, 999, "s", "", true, false, math.huge }
local function mutate(rnd, msg)
local keys = {}
for k in pairs(msg) do
if k ~= "type" then keys[#keys + 1] = k end
end
table.sort(keys)
if #keys == 0 or rnd(1, 100) <= 20 then
msg.type = HOSTILE[rnd(1, #HOSTILE)]
else
msg[keys[rnd(1, #keys)]] = HOSTILE[rnd(1, #HOSTILE)]
end
return msg
end
local function laggyPair(delayA, delayB, rnd, mutateRate)
local a, b = Net.loopbackPair()
a.wire, b.wire = {}, {}
a.delay, b.delay = delayA or 0, delayB or 0
a.mutateRate, b.mutateRate = mutateRate or 0, mutateRate 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 and self.mutateRate > 0 then
if rnd(1, 100) <= self.mutateRate then decoded = mutate(rnd, decoded) end
decoded = Wire.sanitize(decoded)
end
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
end
local function update(self)
@@ -143,7 +169,7 @@ local function firstMismatch(a, b)
end
-- Returns nil when the run agreed, or a description of how it split.
local function runOne(seed)
local function runOne(seed, mutateRate)
local rnd = makeRandom(seed)
-- the two sides are deliberately different clients
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
@@ -155,7 +181,7 @@ local function runOne(seed)
gameA.save.party = randomParty(rnd, rnd(1, 4))
gameB.save.party = randomParty(rnd, rnd(1, 4))
local netA, netB = laggyPair(lagA, lagB)
local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate)
local battleSeed = rnd(1, 2 ^ 30)
local battleA = LinkBattle.newHost(gameA, netA, {
myParty = Protocol.packParty(gameA.save.party),
@@ -230,6 +256,12 @@ local function runOne(seed)
end
end
local turn, part = firstMismatch(battleA, battleB)
if turn and mutateRate then
-- a corrupted action IS a divergence; what matters here is that the
-- match ends by its own rules (desync draw, forfeit, disconnect)
-- rather than throwing
return nil, battleA.turnCount or 0
end
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
@@ -237,7 +269,7 @@ local function runOne(seed)
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
if not mutateRate and 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(
@@ -250,6 +282,8 @@ end
local RUNS = tonumber(arg and arg[1]) or 40
local FIRST = tonumber(arg and arg[2]) or 1
local MUTATION_RUNS = tonumber(arg and arg[3]) or math.max(4, math.floor(RUNS / 4))
local failures, turns = 0, 0
for seed = FIRST, FIRST + RUNS - 1 do
local ok, why, t = pcall(runOne, seed)
@@ -263,5 +297,21 @@ for seed = FIRST, FIRST + RUNS - 1 do
end
end
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
local mutationFailures = 0
for seed = FIRST, FIRST + MUTATION_RUNS - 1 do
local ok, why = pcall(runOne, seed, 15)
if not ok then
mutationFailures = mutationFailures + 1
print("FAIL link mutation fuzz seed " .. seed .. ": " .. tostring(why))
elseif why then
mutationFailures = mutationFailures + 1
print("FAIL link mutation fuzz " .. why)
end
end
print(("link mutation fuzz: %d runs, %d failures"):format(
MUTATION_RUNS, mutationFailures))
assert(failures == 0, failures .. " lockstep run(s) diverged")
assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw")
return true