Files
gen1recomp/tests/engine/restore_poison_steps_bug1971.lua
mleo2003 25073d9ebe fix(checkpoint): keep poisonSteps across a restore (#1971)
`Checkpoint.restore()` rejected most valid checkpoints:

    Checkpoint restoration failed: restored state differed at $.save.poisonSteps

poisonSteps is a plain step counter -- (poisonSteps + 1) % 4 on EVERY step,
not only while a mon is poisoned (OverworldController:applyFieldPoison) -- so
it is non-zero three steps out of four in ordinary play.

Installing the restored world re-enters the map, and the map-enter path zeroes
the counter, correctly mirroring ClearVariablesOnEnterMap. Checkpoint.restore
then re-captures the applied state and compares it against the checkpoint, so
the field it had just discarded failed the comparison and the whole restore
rolled back.

A restore is not a map entry from the player's point of view: the counter
belongs to the state being restored. Carry it across the push.

Why this stayed hidden: the two autosave triggers a checkpoint consumer
naturally uses, map.entered and player.warped, are emitted from inside the
very paths that zero the counter (OverworldController 377/572 and 4664/4783),
so those captures hold 0 and restore cleanly. Only a capture taken at an
arbitrary step -- a manual quicksave, or one tied to the ordinary SAVE --
carries a non-zero value. The T4 title-checkpoint tier misses it for the same
reason: its save is fresh, so the counter is already 0.

The comment above the push claimed Checkpoint.resume was this method's only
caller. Both callers arrive through Checkpoint.apply, which serves resume from
the title session and restore from a settled runtime; that is exactly why the
map-entry side effects matter here. Corrected.

tests/engine/restore_poison_steps_bug1971.lua covers 1..3 and 0 across a
restore, and pins the two behaviours that must NOT change: a plain map entry
still zeroes the counter, and a seamless connection crossing still carries it.
It fails on main (3/6, "got 0, want 3") and passes with this change.
2026-08-31 09:46:45 -07:00

81 lines
3.1 KiB
Lua

-- poisonSteps is a plain step counter -- (poisonSteps + 1) % 4 on EVERY step,
-- not only while a mon is poisoned (OverworldController:applyFieldPoison) --
-- so it is non-zero three steps out of four in ordinary play.
--
-- Restoring a checkpoint re-enters the map, and the map-enter path zeroes it
-- (setMap, mirroring ClearVariablesOnEnterMap). Checkpoint.restore then
-- re-captures the applied state and compares it with the checkpoint, so the
-- field it just discarded fails the comparison and the whole restore rolls
-- back: "restored state differed at $.save.poisonSteps" (#1971).
--
-- A restore is not a map entry from the player's point of view; the counter
-- belongs to the state being restored.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
Data.tilesets.FIX_OUT.tilesPerRow = 16
Data.field.flyWarps = Data.field.flyWarps or {}
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
Data.field.waterTilesets = {}
Data.field.forcedMovement = { tiles = {} }
Data.audio = Data.audio or {}
Data.audio.songs = Data.audio.songs or {}
Data.audio.mapSongs = Data.audio.mapSongs or {}
local SaveData = require("src.core.SaveData")
local Game = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local OverworldState = require("src.world.OverworldController")
Game.data = Data
Game.save = SaveData.newGame()
Game.save.player.name = "RED"
Game.save.player.map = "FIX_TOWN"
StateStack:init()
Game.stack = StateStack
Game.overworld = OverworldState
Game.input = {
isDown = function() return false end,
wasPressed = function() return false end,
step = function() end, state = {}, pressQueue = {},
}
Game.renderer = {
beginWorldPass = function() end, endWorldPass = function() end,
beginUIPass = function() end, endUIPass = function() end,
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
local function checkpointSave(steps)
local save = SaveData.newGame()
save.player.map = "FIX_TOWN"
save.player.x, save.player.y = 4, 4
save.poisonSteps = steps
return save
end
-- === the bug: the counter survives a checkpoint restore
for _, steps in ipairs({ 1, 2, 3 }) do
Game:restoreCheckpointSave(checkpointSave(steps))
T.eq(Game.save.poisonSteps, steps,
("a checkpoint restore keeps poisonSteps = %d"):format(steps))
end
-- === zero stays zero (the case that accidentally worked before)
Game:restoreCheckpointSave(checkpointSave(0))
T.eq(Game.save.poisonSteps, 0, "a checkpoint restore keeps poisonSteps = 0")
-- === an ordinary map entry still clears it, which is the cart behaviour
Game.save.poisonSteps = 3
OverworldState:setMap("FIX_TOWN", 4, 4, "up", {})
T.eq(Game.save.poisonSteps, 0, "a plain map entry still zeroes the counter")
-- === and a seamless connection crossing still does not
Game.save.poisonSteps = 3
OverworldState:setMap("FIX_TOWN", 4, 4, "up", { seamless = true })
T.eq(Game.save.poisonSteps, 3, "a seamless crossing still carries it across")
T.finish("poisonSteps survives a checkpoint restore (#1971)")