mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
fix: allocate playthrough identity only on demand
This commit is contained in:
+10
-3
@@ -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.")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user