-- Data-only and opaque-byte per-mod persistence, scoped by game version and -- opaque playthrough. -- This module is engine-private; Loader exposes only the bound facade methods. local SaveData = require("src.core.SaveData") local SaveSerializer = require("src.core.SaveSerializer") local Version = require("src.core.Version") local Storage = {} Storage.__index = Storage Storage.MAX_BYTES = 512 * 1024 * 1024 local ROOT = "mod_storage" local function failure(code, message) return nil, code, message end local function validSegment(value) return type(value) == "string" and value ~= "" and value:match("^[%w_-]+$") ~= nil end local function validKey(key, allowEmpty) if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end if key == "" then return true end if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then return false end for segment in key:gmatch("[^/]+") do if not validSegment(segment) then return false end end return true end local function ensureParent(fs, path) local dir = path:match("^(.*)/[^/]+$") if dir and fs.createDirectory then fs.createDirectory(dir) end end local function remove(fs, path) if fs.remove then fs.remove(path) end end local function decodeAt(fs, path) if not (fs.getInfo and fs.getInfo(path)) then return nil end local body = fs.read and fs.read(path) if type(body) ~= "string" then return nil end local data = SaveSerializer.decode(body) if not data then return nil end return data, body end local function readOpaqueAt(fs, path) if not (fs.getInfo and fs.getInfo(path)) then return nil end local body = fs.read and fs.read(path) if type(body) ~= "string" then return nil end return body end local function hasAny(fs, paths) for _, path in ipairs(paths) do if fs.getInfo(path) then return true end end return false end function Storage.new(modId, fs) assert(validSegment(modId), "Storage.new needs a safe mod id") return setmetatable({ modId = modId, injectedFs = fs }, Storage) end function Storage:_scope(game) local save = game and game.save local meta = save and save.meta local version = save and save.version 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.") end local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") return { gameVersion = version, playthroughId = playthroughId, base = base, fs = fs } end local function isTitleSession(game) local states = game and game.stack and game.stack.states if type(states) ~= "table" then return false end for _, state in ipairs(states) do if type(state) == "table" and state.screenId == "TitleState" then return true end end return false end -- Bind this mod only to the engine-selected existing playthrough while the -- title session is active. Unlike _scope this must never allocate an identity: -- browsing history before the first normal SAVE is a read of durable state, -- not the start of a New Game. The returned facade closes over its private -- proxy game, so callers cannot substitute another playthrough id or path. function Storage:selected(game) if not isTitleSession(game) then return failure("not_at_title", "Selected playthrough storage is available only from the title session.") end local save = game and game.save local version = save and save.version if not validSegment(version) then return failure("not_in_playthrough", "The title session has no selected game version.") end local playthroughId, code, message = SaveData.selectedPlaythroughId(save, self.injectedFs) if not validSegment(playthroughId) then return failure(code, message) end local selectedGame = { save = { version = version, meta = { playthroughId = playthroughId } }, } local context = { engineVersion = Version.engine, gameVersion = version, playthroughId = playthroughId, } local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs) if type(normal) == "table" and normal.savedAt ~= nil then context.normalSavedAt = normal.savedAt end return { context = function() local copy = { engineVersion = context.engineVersion, gameVersion = context.gameVersion, playthroughId = context.playthroughId, } if context.normalSavedAt ~= nil then copy.normalSavedAt = context.normalSavedAt end return copy end, read = function(_, key) return self:read(selectedGame, key) end, write = function(_, key, value) return self:write(selectedGame, key, value) end, readBytes = function(_, key) return self:readBytes(selectedGame, key) end, writeBytes = function(_, key, bytes) return self:writeBytes(selectedGame, key, bytes) end, list = function(_, prefix) return self:list(selectedGame, prefix) end, delete = function(_, key) return self:delete(selectedGame, key) end, } end function Storage:context(game) local scope, code, message = self:_scope(game) if not scope then return nil, code, message end return { engineVersion = Version.engine, gameVersion = scope.gameVersion, playthroughId = scope.playthroughId, } end function Storage:_names(game, key, allowEmpty, extension) if not validKey(key, allowEmpty) then return failure("invalid_key", "Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") end local scope, code, message = self:_scope(game) if not scope then return nil, code, message end local path = scope.base .. (key ~= "" and ("/" .. key) or "") extension = extension or ".lua" return scope, path .. extension, path .. extension .. ".bak", path .. extension .. ".tmp", path end function Storage:write(game, key, value) local scope, main, bak, tmp, path = self:_names(game, key, false) if not scope then return false, main, bak end local fs = scope.fs if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then return false, "type_conflict", "A byte value already exists for this storage key; delete it first." end if type(value) ~= "table" then return false, "encode_failed", "Storage values must be data-only tables." end local encodedOk, encoded = pcall(SaveSerializer.encode, value) if not encodedOk then return false, "encode_failed", "Storage value is not serializable data: " .. tostring(encoded) end ensureParent(fs, main) local _, previous = decodeAt(fs, main) if not previous then _, previous = decodeAt(fs, bak) end local ok, err = fs.write(tmp, encoded) if not ok then return false, "write_failed", "Could not stage storage data: " .. tostring(err) end local staged = decodeAt(fs, tmp) if not staged then remove(fs, tmp) return false, "verify_failed", "Staged storage data could not be verified." end if previous then fs.write(bak, previous) end ok, err = fs.write(main, encoded) if not ok then remove(fs, tmp) return false, "write_failed", "Could not replace storage data: " .. tostring(err) end local verified = decodeAt(fs, main) if not verified then remove(fs, main) remove(fs, tmp) return false, "verify_failed", "Replacement storage data could not be verified." end -- At rest both main and backup hold the newest verified record. If a later -- write dies after rolling this copy aside, one verified generation remains. fs.write(bak, encoded) remove(fs, tmp) return true end function Storage:read(game, key) local scope, main, bak, tmp, path = self:_names(game, key, false) if not scope then return nil, main, bak end local fs = scope.fs if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then return failure("type_mismatch", "This storage key contains opaque bytes; use readBytes instead.") end local data, body = decodeAt(fs, main) if data then return data end data, body = decodeAt(fs, tmp) if not data then data, body = decodeAt(fs, bak) end if not data then return nil, "not_found", "No valid stored value exists for this key." end -- Best-effort healing. The recovered copy remains in tmp/bak if promotion -- cannot land, so returning it is still safe and the next read can retry. ensureParent(fs, main) if fs.write(main, body) then fs.write(bak, body) end remove(fs, tmp) return data end function Storage:writeBytes(game, key, bytes) local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") if not scope then return false, main, bak end if type(bytes) ~= "string" then return false, "invalid_bytes", "Opaque storage values must be strings." end if #bytes > Storage.MAX_BYTES then return false, "size_limit", ("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES) end local fs = scope.fs if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then return false, "type_conflict", "A table value already exists for this storage key; delete it first." end ensureParent(fs, main) local previous = readOpaqueAt(fs, main) if previous == nil then previous = readOpaqueAt(fs, bak) end local ok, err = fs.write(tmp, bytes) if not ok then return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err) end local staged = readOpaqueAt(fs, tmp) if staged == nil or staged ~= bytes then remove(fs, tmp) return false, "verify_failed", "Staged opaque storage data could not be verified." end if previous ~= nil then fs.write(bak, previous) end ok, err = fs.write(main, bytes) if not ok then remove(fs, tmp) return false, "write_failed", "Could not replace opaque storage data: " .. tostring(err) end local verified = readOpaqueAt(fs, main) if verified == nil or verified ~= bytes then remove(fs, main) remove(fs, tmp) return false, "verify_failed", "Replacement opaque storage data could not be verified." end fs.write(bak, bytes) remove(fs, tmp) return true end function Storage:readBytes(game, key) local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") if not scope then return nil, main, bak end local fs = scope.fs if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then return failure("type_mismatch", "This storage key contains table data; use read instead.") end local bytes = readOpaqueAt(fs, main) if bytes ~= nil then return bytes end bytes = readOpaqueAt(fs, tmp) if bytes == nil then bytes = readOpaqueAt(fs, bak) end if bytes == nil then return nil, "not_found", "No valid opaque value exists for this key." end ensureParent(fs, main) if fs.write(main, bytes) then fs.write(bak, bytes) end remove(fs, tmp) return bytes end function Storage:list(game, prefix) prefix = prefix or "" local scope, main, codeOrBak = self:_names(game, prefix, true) if not scope then return nil, main, codeOrBak end local fs = scope.fs if not fs.getDirectoryItems then return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." end local base = scope.base local start = prefix == "" and base or (base .. "/" .. prefix) local out, seen = {}, {} local function add(logical) if not seen[logical] then seen[logical] = true out[#out + 1] = logical end end local function walk(path, logical) local info = fs.getInfo(path) if not info then return end if info.type == "file" then local suffix = path:sub(-4) if suffix == ".lua" or suffix == ".bin" then add(logical:sub(1, -5)) end return end for _, child in ipairs(fs.getDirectoryItems(path) or {}) do local childLogical = logical == "" and child or (logical .. "/" .. child) walk(path .. "/" .. child, childLogical) end end -- A prefix may identify one exact key or a directory of keys. if fs.getInfo(start .. ".lua") then add(prefix) elseif fs.getInfo(start .. ".bin") then add(prefix) else walk(start, prefix) end table.sort(out) return out end function Storage:delete(game, key) local scope, main, bak, tmp, path = self:_names(game, key, false) if not scope then return false, main, bak end local fs = scope.fs local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp) or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then return false, "not_found", "No stored value exists for this key." end remove(fs, main) remove(fs, bak) remove(fs, tmp) remove(fs, byteMain) remove(fs, byteBak) remove(fs, byteTmp) return true end return Storage