fix: allocate playthrough identity only on demand

This commit is contained in:
MaxTomahawk
2026-08-07 15:01:18 +02:00
parent 6e94625f2a
commit 49954ec4ad
6 changed files with 80 additions and 39 deletions
+10 -3
View File
@@ -2,6 +2,7 @@
-- methods; mods never receive controller or state-stack internals from here. -- methods; mods never receive controller or state-stack internals from here.
local SaveSerializer = require("src.core.SaveSerializer") local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local Checkpoint = {} local Checkpoint = {}
@@ -27,9 +28,7 @@ end
function Checkpoint.inspect(game) function Checkpoint.inspect(game)
local save = game and game.save local save = game and game.save
local identity = save and save.meta and save.meta.playthroughId if type(save) ~= "table" or type(save.version) ~= "string" then
if type(save) ~= "table" or type(save.version) ~= "string"
or type(identity) ~= "string" or identity == "" then
return refusal("unknown", "not_in_playthrough", return refusal("unknown", "not_in_playthrough",
"A checkpoint requires an identified active playthrough.") "A checkpoint requires an identified active playthrough.")
end end
@@ -45,6 +44,14 @@ function Checkpoint.inspect(game)
return refusal("overworld", "screen_busy", return refusal("overworld", "screen_busy",
"Close the active menu or screen before creating a checkpoint.") "Close the active menu or screen before creating a checkpoint.")
end end
local identity = save.meta and save.meta.playthroughId
if type(identity) ~= "string" or identity == "" then
identity = SaveData.ensurePlaythroughId(save)
end
if type(identity) ~= "string" or identity == "" then
return refusal("overworld", "not_in_playthrough",
"The active playthrough could not be identified.")
end
if ow.transitioning then if ow.transitioning then
return refusal("overworld", "transition_busy", return refusal("overworld", "transition_busy",
"Wait for the map transition to finish.") "Wait for the map transition to finish.")
-1
View File
@@ -1079,7 +1079,6 @@ function Game:restoreSave(loaded, recovered)
if ModRuntime.wants("save.loading") then if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded }) ModRuntime.emit("save.loading", { raw = loaded })
end end
SaveData.ensurePlaythroughId(loaded)
-- mod chains replay before validation so a mod repairs its own data -- mod chains replay before validation so a mod repairs its own data
-- instead of watching it get quarantined; core steps already ran in -- instead of watching it get quarantined; core steps already ran in
-- SaveData.load and skip on the format guard -- SaveData.load and skip on the format guard
+29 -21
View File
@@ -486,6 +486,10 @@ end
-- working unchanged. -- working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process local slotsChecked = {} -- version -> true once resolved this process
-- At most one New Game can be the live candidate for a first public tool
-- request. A single strong reference models that runtime fact without adding
-- marker data to the save or retaining abandoned playthrough tables.
local freshPlaythrough
local function slotDir(version) return "saves/" .. version end local function slotDir(version) return "saves/" .. version end
@@ -822,6 +826,7 @@ end
function SaveData.resetSlotState() function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end
freshPlaythrough = nil
end end
-- ------- opaque playthrough identity -- ------- opaque playthrough identity
@@ -846,20 +851,20 @@ function SaveData.newPlaythroughId()
word(os.time()), word(clock), word(addressLo), word(playthroughSeq)) word(os.time()), word(clock), word(addressLo), word(playthroughSeq))
end end
local function playthroughScope(version) local function playthroughScope(version, injectedFs)
version = version or GameVersion.get() version = version or GameVersion.get()
local fs = persistFs(nil) local fs = persistFs(injectedFs)
ensureVersionSlots(version, fs) ensureVersionSlots(version, fs)
return activeSlotCache[version] or "legacy" return activeSlotCache[version] or "legacy"
end end
local function rememberPlaythroughId(save, opts) local function rememberPlaythroughId(save, opts, injectedFs)
local meta = type(save) == "table" and save.meta local meta = type(save) == "table" and save.meta
local id = type(meta) == "table" and meta.playthroughId local id = type(meta) == "table" and meta.playthroughId
if type(id) ~= "string" or id == "" then return opts, false end if type(id) ~= "string" or id == "" then return opts, false end
local version = save.version or GameVersion.get() local version = save.version or GameVersion.get()
local scope = playthroughScope(version) local scope = playthroughScope(version, injectedFs)
opts = opts or SaveData.loadOptions() opts = opts or SaveData.loadOptions(injectedFs)
opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {}
local changed = opts.playthroughIds[version][scope] ~= id local changed = opts.playthroughIds[version][scope] ~= id
@@ -870,23 +875,25 @@ end
-- Return an existing save identity or give a pre-identity save a stable one. -- Return an existing save identity or give a pre-identity save a stable one.
-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id -- Legacy backfill lives in options.lua until the next normal SAVE stamps the id
-- into progress, so installing a tool mod never rewrites the player's checkpoint. -- into progress, so installing a tool mod never rewrites the player's checkpoint.
function SaveData.ensurePlaythroughId(save) function SaveData.ensurePlaythroughId(save, injectedFs)
if type(save) ~= "table" then return nil end if type(save) ~= "table" then return nil end
save.meta = type(save.meta) == "table" and save.meta or {} save.meta = type(save.meta) == "table" and save.meta or {}
local id = save.meta.playthroughId local id = save.meta.playthroughId
if type(id) == "string" and id ~= "" then return id end if type(id) == "string" and id ~= "" then return id end
local version = save.version or GameVersion.get() local version = save.version or GameVersion.get()
local scope = playthroughScope(version) local scope = playthroughScope(version, injectedFs)
local opts = SaveData.loadOptions() local opts = SaveData.loadOptions(injectedFs)
local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version] local byVersion = opts.playthroughIds and opts.playthroughIds[version]
id = byVersion and byVersion[scope] id = not isFresh and byVersion and byVersion[scope] or nil
if type(id) ~= "string" or id == "" then if type(id) ~= "string" or id == "" then
id = SaveData.newPlaythroughId() id = SaveData.newPlaythroughId()
opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts) SaveData.saveOptions(opts, injectedFs)
end end
save.meta.playthroughId = id save.meta.playthroughId = id
return id return id
@@ -1123,12 +1130,14 @@ function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active -- write to the file matching this save's own version, not just the active
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version) local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
SaveData.ensurePlaythroughId(data)
if data.options then if data.options then
local opts = rememberPlaythroughId(data, data.options) local opts = data.options
if data.meta and data.meta.playthroughId then
opts = rememberPlaythroughId(data, data.options)
end
data.options = opts data.options = opts
SaveData.saveOptions(opts) SaveData.saveOptions(opts)
else elseif data.meta and data.meta.playthroughId then
local opts, changed = rememberPlaythroughId(data) local opts, changed = rememberPlaythroughId(data)
if changed then SaveData.saveOptions(opts) end if changed then SaveData.saveOptions(opts) end
end end
@@ -1199,7 +1208,6 @@ function SaveData.load(version)
return nil return nil
end end
SaveData.runMigrations(data) SaveData.runMigrations(data)
SaveData.ensurePlaythroughId(data)
data.options = SaveData.loadOptions() data.options = SaveData.loadOptions()
Logger.info("loaded save") Logger.info("loaded save")
return data, recovered return data, recovered
@@ -1510,11 +1518,7 @@ function SaveData.newGame(boot)
local x, y = boot.startX or 3, boot.startY or 6 local x, y = boot.startX or 3, boot.startY or 6
local heal = SaveData.defaultHeal(boot) local heal = SaveData.defaultHeal(boot)
local save = { local save = {
meta = { meta = { format = Version.saveFormat, mods = {} },
format = Version.saveFormat,
mods = {},
playthroughId = SaveData.newPlaythroughId(),
},
-- which game this playthrough is (Red vs Blue). Only Red ships today; -- which game this playthrough is (Red vs Blue). Only Red ships today;
-- boot carries the choice once Blue support lands. -- boot carries the choice once Blue support lands.
version = boot.version or "red", version = boot.version or "red",
@@ -1557,8 +1561,12 @@ function SaveData.newGame(boot)
options = SaveData.loadOptions(), options = SaveData.loadOptions(),
} }
-- a total conversion reshapes the skeleton (spawn, party, money) -- a total conversion reshapes the skeleton (spawn, party, money)
-- before anything reads it; unhooked this returns save unchanged -- before anything reads it; unhooked this returns save unchanged. Keep the
return Runtime.call("save.new_game", function(s) return s end, save) -- "fresh playthrough" marker outside the serialized table so a later tool
-- request can distinguish two unsaved New Games sharing one vanilla slot.
save = Runtime.call("save.new_game", function(s) return s end, save)
freshPlaythrough = save
return save
end end
return SaveData return SaveData
+7
View File
@@ -41,6 +41,13 @@ local function serialize(v, indent)
error("cannot serialize " .. t) error("cannot serialize " .. t)
end end
-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands
-- inside a compiled recursive serialization trace. The symptom is valid input
-- becoming `{" followed by only the trailing comma, which then cannot be read
-- back. Save encoding is infrequent and I/O-bound, so keep this correctness-
-- critical recursion in the interpreter while leaving the game JIT enabled.
if jit and jit.off then jit.off(serialize, true) end
function SaveSerializer.encode(data) function SaveSerializer.encode(data)
return "return " .. serialize(data) .. "\n" return "return " .. serialize(data) .. "\n"
end end
+9 -2
View File
@@ -57,11 +57,18 @@ function Storage:_scope(game)
local save = game and game.save local save = game and game.save
local meta = save and save.meta local meta = save and save.meta
local version = save and save.version local version = save and save.version
local playthroughId = meta and meta.playthroughId if not (save and validSegment(version)) then
if not (save and validSegment(version) and validSegment(playthroughId)) then
return failure("not_in_playthrough", return failure("not_in_playthrough",
"Storage is available only inside an identified playthrough.") "Storage is available only inside an identified playthrough.")
end end
local playthroughId = meta and meta.playthroughId
if not validSegment(playthroughId) then
playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs)
end
if not validSegment(playthroughId) then
return failure("not_in_playthrough",
"Storage could not identify the active playthrough.")
end
local fs = SaveData.persistenceFs(self.injectedFs) local fs = SaveData.persistenceFs(self.injectedFs)
if not (fs and fs.read and fs.write and fs.getInfo) then if not (fs and fs.read and fs.write and fs.getInfo) then
return failure("storage_unavailable", "The persistence backend is unavailable.") return failure("storage_unavailable", "The persistence backend is unavailable.")
+25 -12
View File
@@ -61,23 +61,31 @@ local function legacy(version, name)
} }
end end
-- Removing playthroughId generation from New Game must fail these assertions. -- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope.
do do
fresh() fresh()
local first = SaveData.newGame({ version = "red" }) local first = SaveData.newGame({ version = "red" })
local second = SaveData.newGame({ version = "red" }) local second = SaveData.newGame({ version = "red" })
T.check(type(first.meta.playthroughId) == "string" T.eq(first.meta.playthroughId, nil,
and first.meta.playthroughId ~= "", "New Game allocates no playthrough id before a public tool requests it")
"New Game receives an opaque playthrough id") T.check(SaveData.save(first), "unused identity fixture saves")
T.neq(second.meta.playthroughId, first.meta.playthroughId, local untouched = SaveData.load("red")
"separate New Games receive separate playthrough ids") T.eq(untouched.meta.playthroughId, nil,
"normal save/load stays identity-free when no tool uses the capability")
local firstId = SaveData.ensurePlaythroughId(first)
local secondId = SaveData.ensurePlaythroughId(second)
T.check(type(firstId) == "string" and firstId ~= "",
"the first tool request allocates an opaque playthrough id")
T.neq(secondId, firstId,
"separate New Games receive separate requested playthrough ids")
end end
-- Dropping the id from buildMeta or save encoding must fail the roundtrip. -- Dropping the id from buildMeta or save encoding must fail the roundtrip.
do do
fresh() fresh()
local save = SaveData.newGame({ version = "red" }) local save = SaveData.newGame({ version = "red" })
local expected = save.meta.playthroughId local expected = SaveData.ensurePlaythroughId(save)
T.check(SaveData.save(save), "identity fixture saves") T.check(SaveData.save(save), "identity fixture saves")
local loaded = SaveData.load("red") local loaded = SaveData.load("red")
T.eq(loaded and loaded.meta.playthroughId, expected, T.eq(loaded and loaded.meta.playthroughId, expected,
@@ -92,9 +100,14 @@ do
files["save.lua"] = SaveSerializer.encode(raw) files["save.lua"] = SaveSerializer.encode(raw)
local first = SaveData.load("red") local first = SaveData.load("red")
local id = first and first.meta.playthroughId T.eq(first and first.meta.playthroughId, nil,
"loading a legacy save alone does not allocate tool identity")
local id = SaveData.ensurePlaythroughId(first)
T.check(type(id) == "string" and id ~= "", T.check(type(id) == "string" and id ~= "",
"a legacy save receives a playthrough id") "a legacy save receives a playthrough id")
local mappedOptions, mappedErr = SaveSerializer.decode(files["options.lua"] or "")
T.check(mappedOptions ~= nil,
"legacy identity mapping remains decodable: " .. tostring(mappedErr))
local slotBytes = files["saves/red/slot1.lua"] local slotBytes = files["saves/red/slot1.lua"]
local onDisk = slotBytes and SaveSerializer.decode(slotBytes) local onDisk = slotBytes and SaveSerializer.decode(slotBytes)
@@ -103,7 +116,7 @@ do
SaveData.resetSlotState() SaveData.resetSlotState()
local second = SaveData.load("red") local second = SaveData.load("red")
T.eq(second and second.meta.playthroughId, id, T.eq(SaveData.ensurePlaythroughId(second), id,
"legacy backfill is stable across reload before normal SAVE") "legacy backfill is stable across reload before normal SAVE")
end end
@@ -115,19 +128,19 @@ do
SaveData.setActiveSlot("red", redA) SaveData.setActiveSlot("red", redA)
T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")), T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")),
"seed red slot A") "seed red slot A")
local idA = SaveData.load("red").meta.playthroughId local idA = SaveData.ensurePlaythroughId(SaveData.load("red"))
SaveData.setActiveSlot("red", redB) SaveData.setActiveSlot("red", redB)
T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")), T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")),
"seed red slot B") "seed red slot B")
local idB = SaveData.load("red").meta.playthroughId local idB = SaveData.ensurePlaythroughId(SaveData.load("red"))
GameVersion.set("blue") GameVersion.set("blue")
local blue = SaveData.createSlot("blue") local blue = SaveData.createSlot("blue")
SaveData.setActiveSlot("blue", blue) SaveData.setActiveSlot("blue", blue)
T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")), T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")),
"seed blue slot") "seed blue slot")
local idBlue = SaveData.load("blue").meta.playthroughId local idBlue = SaveData.ensurePlaythroughId(SaveData.load("blue"))
T.neq(idA, idB, "two active slots do not share legacy identity") T.neq(idA, idB, "two active slots do not share legacy identity")
T.neq(idA, idBlue, "Red and Blue do not share legacy identity") T.neq(idA, idBlue, "Red and Blue do not share legacy identity")