mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
400 lines
13 KiB
Lua
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
|