SaveData: a fresh skeleton must not overwrite an existing playthrough binding

ensurePlaythroughId() treats a fresh New Game skeleton as having no id, mints
one, and persists it into opts.playthroughIds[version][scope] -- even when that
slot already names a playthrough.

newGame() marks the skeleton on the boot frame, before any save is loaded, and
mods initialise inside that window: Storage:selected needs TitleState, which
does not exist yet, so Storage:context -> _scope -> ensurePlaythroughId is the
only path open to them. A mod touching mod.storage at init therefore replaces
the real save's id with a throwaway, stranding that save's mod storage, and it
repeats on every launch.

Observed on an RG35XXSP (engine 0.2.1, PotatoVoxel 1.7.11): a new playthrough
id in options.lua after every launch, 32 orphaned mod_storage directories, and
the mod's ~400MB prebuilt mesh cache abandoned under the id options.lua used to
name -- so every map rebuilt from scratch.

Keep both existing behaviours: a fresh skeleton still gets its own id, so two
unsaved New Games sharing a slot stay distinct, and it is still persisted when
the slot has no binding yet -- the contract tests/modkit/cases/
title_playthrough_context.lua pins, where a tool persists before the first
normal SAVE and the title must resolve it after a restart.

Only the overwrite of an EXISTING binding is dropped.

./scripts/test.sh: ALL TIERS PASSED (44/44 title_playthrough_context,
18/18 playthrough_identity).
This commit is contained in:
mleo2003
2026-08-17 14:47:52 -07:00
parent 22bcd95da1
commit 142d1358dd
+18 -5
View File
@@ -1250,13 +1250,26 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
local isFresh = save == freshPlaythrough local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version] local byVersion = opts.playthroughIds and opts.playthroughIds[version]
id = not isFresh and byVersion and byVersion[scope] or nil local existing = byVersion and byVersion[scope]
id = not isFresh and existing 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 {} -- A fresh skeleton still gets its own id (two unsaved New Games sharing a
opts.playthroughIds[version] = opts.playthroughIds[version] or {} -- slot must stay distinct), and it is still persisted when the slot has no
opts.playthroughIds[version][scope] = id -- binding yet -- that is the contract a tool relies on to resolve
SaveData.saveOptions(opts, injectedFs) -- `selected` at the title after a restart, before any normal SAVE.
--
-- What it must NOT do is OVERWRITE a binding that already exists. newGame()
-- marks a skeleton on the boot frame, before any save is loaded, and mods
-- initialise inside that window -- so a mod touching storage at init
-- replaced the real save's id with a throwaway, stranding that save's mod
-- storage and repeating on every launch.
if not (isFresh and type(existing) == "string" and existing ~= "") then
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
end end
save.meta.playthroughId = id save.meta.playthroughId = id
return id return id