diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index b29caba8..07427c94 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -2,6 +2,7 @@ -- methods; mods never receive controller or state-stack internals from here. local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") local Checkpoint = {} @@ -27,9 +28,7 @@ end function Checkpoint.inspect(game) 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" - or type(identity) ~= "string" or identity == "" then + if type(save) ~= "table" or type(save.version) ~= "string" then return refusal("unknown", "not_in_playthrough", "A checkpoint requires an identified active playthrough.") end @@ -45,6 +44,14 @@ function Checkpoint.inspect(game) return refusal("overworld", "screen_busy", "Close the active menu or screen before creating a checkpoint.") 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 return refusal("overworld", "transition_busy", "Wait for the map transition to finish.") diff --git a/src/core/Game.lua b/src/core/Game.lua index 527f4fc5..27d585b0 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1079,7 +1079,6 @@ function Game:restoreSave(loaded, recovered) if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end - SaveData.ensurePlaythroughId(loaded) -- mod chains replay before validation so a mod repairs its own data -- instead of watching it get quarantined; core steps already ran in -- SaveData.load and skip on the format guard diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index f68a10b4..2cba7e6f 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -486,6 +486,10 @@ end -- working unchanged. local activeSlotCache = {} -- version -> slotId in use, or false when none 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 @@ -822,6 +826,7 @@ end function SaveData.resetSlotState() for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end + freshPlaythrough = nil end -- ------- opaque playthrough identity @@ -846,20 +851,20 @@ function SaveData.newPlaythroughId() word(os.time()), word(clock), word(addressLo), word(playthroughSeq)) end -local function playthroughScope(version) +local function playthroughScope(version, injectedFs) version = version or GameVersion.get() - local fs = persistFs(nil) + local fs = persistFs(injectedFs) ensureVersionSlots(version, fs) return activeSlotCache[version] or "legacy" end -local function rememberPlaythroughId(save, opts) +local function rememberPlaythroughId(save, opts, injectedFs) local meta = type(save) == "table" and save.meta local id = type(meta) == "table" and meta.playthroughId if type(id) ~= "string" or id == "" then return opts, false end local version = save.version or GameVersion.get() - local scope = playthroughScope(version) - opts = opts or SaveData.loadOptions() + local scope = playthroughScope(version, injectedFs) + opts = opts or SaveData.loadOptions(injectedFs) opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {} 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. -- 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. -function SaveData.ensurePlaythroughId(save) +function SaveData.ensurePlaythroughId(save, injectedFs) if type(save) ~= "table" then return nil end save.meta = type(save.meta) == "table" and save.meta or {} local id = save.meta.playthroughId if type(id) == "string" and id ~= "" then return id end local version = save.version or GameVersion.get() - local scope = playthroughScope(version) - local opts = SaveData.loadOptions() + local scope = playthroughScope(version, injectedFs) + local opts = SaveData.loadOptions(injectedFs) + local isFresh = save == freshPlaythrough + if isFresh then freshPlaythrough = nil end 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 id = SaveData.newPlaythroughId() opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {} opts.playthroughIds[version][scope] = id - SaveData.saveOptions(opts) + SaveData.saveOptions(opts, injectedFs) end save.meta.playthroughId = 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 -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version) - SaveData.ensurePlaythroughId(data) 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 SaveData.saveOptions(opts) - else + elseif data.meta and data.meta.playthroughId then local opts, changed = rememberPlaythroughId(data) if changed then SaveData.saveOptions(opts) end end @@ -1199,7 +1208,6 @@ function SaveData.load(version) return nil end SaveData.runMigrations(data) - SaveData.ensurePlaythroughId(data) data.options = SaveData.loadOptions() Logger.info("loaded save") return data, recovered @@ -1510,11 +1518,7 @@ function SaveData.newGame(boot) local x, y = boot.startX or 3, boot.startY or 6 local heal = SaveData.defaultHeal(boot) local save = { - meta = { - format = Version.saveFormat, - mods = {}, - playthroughId = SaveData.newPlaythroughId(), - }, + meta = { format = Version.saveFormat, mods = {} }, -- which game this playthrough is (Red vs Blue). Only Red ships today; -- boot carries the choice once Blue support lands. version = boot.version or "red", @@ -1557,8 +1561,12 @@ function SaveData.newGame(boot) options = SaveData.loadOptions(), } -- a total conversion reshapes the skeleton (spawn, party, money) - -- before anything reads it; unhooked this returns save unchanged - return Runtime.call("save.new_game", function(s) return s end, save) + -- before anything reads it; unhooked this returns save unchanged. Keep the + -- "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 return SaveData diff --git a/src/core/SaveSerializer.lua b/src/core/SaveSerializer.lua index 77ba482d..9f821f67 100644 --- a/src/core/SaveSerializer.lua +++ b/src/core/SaveSerializer.lua @@ -41,6 +41,13 @@ local function serialize(v, indent) error("cannot serialize " .. t) 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) return "return " .. serialize(data) .. "\n" end diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index 9b80c360..d5f3a324 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -57,11 +57,18 @@ function Storage:_scope(game) local save = game and game.save local meta = save and save.meta local version = save and save.version - local playthroughId = meta and meta.playthroughId - if not (save and validSegment(version) and validSegment(playthroughId)) then + if not (save and validSegment(version)) then return failure("not_in_playthrough", "Storage is available only inside an identified playthrough.") 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) if not (fs and fs.read and fs.write and fs.getInfo) then return failure("storage_unavailable", "The persistence backend is unavailable.") diff --git a/tests/engine/playthrough_identity.lua b/tests/engine/playthrough_identity.lua index 291433a1..cb584d51 100644 --- a/tests/engine/playthrough_identity.lua +++ b/tests/engine/playthrough_identity.lua @@ -61,23 +61,31 @@ local function legacy(version, name) } end --- Removing playthroughId generation from New Game must fail these assertions. +-- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope. do fresh() local first = SaveData.newGame({ version = "red" }) local second = SaveData.newGame({ version = "red" }) - T.check(type(first.meta.playthroughId) == "string" - and first.meta.playthroughId ~= "", - "New Game receives an opaque playthrough id") - T.neq(second.meta.playthroughId, first.meta.playthroughId, - "separate New Games receive separate playthrough ids") + T.eq(first.meta.playthroughId, nil, + "New Game allocates no playthrough id before a public tool requests it") + T.check(SaveData.save(first), "unused identity fixture saves") + local untouched = SaveData.load("red") + 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 -- Dropping the id from buildMeta or save encoding must fail the roundtrip. do fresh() local save = SaveData.newGame({ version = "red" }) - local expected = save.meta.playthroughId + local expected = SaveData.ensurePlaythroughId(save) T.check(SaveData.save(save), "identity fixture saves") local loaded = SaveData.load("red") T.eq(loaded and loaded.meta.playthroughId, expected, @@ -92,9 +100,14 @@ do files["save.lua"] = SaveSerializer.encode(raw) 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 ~= "", "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 onDisk = slotBytes and SaveSerializer.decode(slotBytes) @@ -103,7 +116,7 @@ do SaveData.resetSlotState() 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") end @@ -115,19 +128,19 @@ do SaveData.setActiveSlot("red", redA) T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")), "seed red slot A") - local idA = SaveData.load("red").meta.playthroughId + local idA = SaveData.ensurePlaythroughId(SaveData.load("red")) SaveData.setActiveSlot("red", redB) T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")), "seed red slot B") - local idB = SaveData.load("red").meta.playthroughId + local idB = SaveData.ensurePlaythroughId(SaveData.load("red")) GameVersion.set("blue") local blue = SaveData.createSlot("blue") SaveData.setActiveSlot("blue", blue) T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")), "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, idBlue, "Red and Blue do not share legacy identity")