Files
gen1recomp/src/mods/Storage.lua
T
2026-08-14 21:42:45 +01:00

400 lines
13 KiB
Lua

-- 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