From 142d1358ddef6044aba074433ee0b94f78a492d6 Mon Sep 17 00:00:00 2001 From: mleo2003 Date: Mon, 17 Aug 2026 14:47:52 -0700 Subject: [PATCH] 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). --- src/core/SaveData.lua | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index a63db2a4..66ec6a11 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1250,13 +1250,26 @@ function SaveData.ensurePlaythroughId(save, injectedFs) local isFresh = save == freshPlaythrough if isFresh then freshPlaythrough = nil end 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 id = SaveData.newPlaythroughId() - opts.playthroughIds = opts.playthroughIds or {} - opts.playthroughIds[version] = opts.playthroughIds[version] or {} - opts.playthroughIds[version][scope] = id - SaveData.saveOptions(opts, injectedFs) + -- A fresh skeleton still gets its own id (two unsaved New Games sharing a + -- slot must stay distinct), and it is still persisted when the slot has no + -- binding yet -- that is the contract a tool relies on to resolve + -- `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 save.meta.playthroughId = id return id