diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index 1f375283..f6827fa8 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -156,6 +156,27 @@ local function dataCopy(value) return decoded end +local function captureRng() + local getState = love and love.math and love.math.getRandomState + local setState = love and love.math and love.math.setRandomState + if type(getState) ~= "function" or type(setState) ~= "function" then return nil end + local ok, state = pcall(getState) + if ok and type(state) == "string" and state ~= "" then + return { love = state } + end + return nil +end + +local function restoreRng(rng) + if rng == nil then return end -- legacy format-1 overworld checkpoint + local setState = love and love.math and love.math.setRandomState + if type(rng) ~= "table" or type(rng.love) ~= "string" + or type(setState) ~= "function" then + error("checkpoint RNG restore is unavailable", 0) + end + setState(rng.love) +end + function Checkpoint.capture(game) local capability = Checkpoint.inspect(game) if not capability.canCapture then @@ -218,6 +239,7 @@ function Checkpoint.capture(game) facing = player.facing, surfing = player.surfing and true or false, } }, + rng = captureRng(), } end @@ -264,6 +286,10 @@ local function validate(game, checkpoint) or not save.meta or save.meta.playthroughId ~= identity.playthroughId then return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent." end + if copy.rng ~= nil and (type(copy.rng) ~= "table" + or type(copy.rng.love) ~= "string" or copy.rng.love == "") then + return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt." + end if type(runtime.map) ~= "string" or type(runtime.x) ~= "number" or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0 or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then @@ -300,7 +326,7 @@ local function validate(game, checkpoint) if copy.kind == "battle" then local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy) if not battleOk then return nil, battleCode, battleMessage end - elseif copy.runtime.battle ~= nil or copy.rng ~= nil then + elseif copy.runtime.battle ~= nil then return nil, "invalid_checkpoint", "Overworld checkpoint contains unexpected battle state." end @@ -323,6 +349,8 @@ local function apply(game, checkpoint, options) game:restoreCheckpointSave(save) if checkpoint.kind == "battle" then BattleCheckpoint.restore(game, checkpoint, dataCopy) + else + restoreRng(checkpoint.rng) end end @@ -369,6 +397,7 @@ function Checkpoint.restore(game, checkpoint) local ok, err = pcall(apply, game, validated, options) if ok then local restored, verifyCode = Checkpoint.capture(game) + if restored and validated.rng == nil then restored.rng = nil end if restored and equalData(restored, validated) then return true end err = restored and ("restored state differed at " .. tostring(firstDifference(validated, restored) or "canonical encoding")) diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index a1277b1e..4c26ef87 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -4,6 +4,12 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = love or require("tests.love_stub") +local oldGetRandomState = love.math.getRandomState +local oldSetRandomState = love.math.setRandomState +local checkpointRngState = "overworld-rng-A" +love.math.getRandomState = function() return checkpointRngState end +love.math.setRandomState = function(state) checkpointRngState = state end + local T = require("tests.harness").suite("mod checkpoints") local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") @@ -217,6 +223,19 @@ T.same(snapshot.runtime.overworld, T.eq(snapshot.save.player.map, "ROUTE_1", "captured progress is synchronized from the live controller") T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind") +T.same(snapshot.rng, { love = "overworld-rng-A" }, + "overworld checkpoint carries deterministic gameplay RNG") + +local legacy = checkpoints:capture(game) +legacy.rng = nil +checkpointRngState = "legacy-runtime-rng" +local legacyRestored, legacyCode = checkpoints:restore(game, legacy) +T.check(legacyRestored == true, + "legacy format-1 overworld checkpoint without RNG remains loadable: " + .. tostring(legacyCode)) +T.eq(checkpointRngState, "legacy-runtime-rng", + "legacy checkpoint leaves the current RNG stream untouched") +checkpointRngState = "overworld-rng-A" snapshot.save.money = 1 snapshot.runtime.overworld.x = 1 @@ -231,6 +250,7 @@ game.save.money = 999999 game.save.flags.GOT_STARTER = nil game.save.party[1].hp = 1 game.save.options.volume = 9 +checkpointRngState = "overworld-rng-B" ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3 ow.player.facing, ow.player.surfing = "up", false @@ -242,6 +262,8 @@ T.same(recaptured, original, "capture A, mutate B, restore A, capture A2 yields normalized A == A2") T.eq(game.save.options.volume, 9, "checkpoint restoration preserves current global settings") +T.eq(checkpointRngState, "overworld-rng-A", + "overworld checkpoint restores gameplay RNG") T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true, "engine reconstruction is marked to suppress map-entry side effects") @@ -301,5 +323,7 @@ T.same(checkpoints:capture(game), beforeFailure, Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil _G.MOD_CHECKPOINTS = nil +love.math.getRandomState = oldGetRandomState +love.math.setRandomState = oldSetRandomState T.finish()