Add opaque byte storage to mod API

This commit is contained in:
Shane McGovern
2026-08-14 21:42:45 +01:00
parent 797a6bebfe
commit 9fab992d42
6 changed files with 321 additions and 31 deletions
+33 -11
View File
@@ -308,6 +308,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
``` ```
For independently generated binary data, use the opaque byte methods. They
accept and return the exact Lua string of bytes, including NUL bytes and bytes
that are not valid text:
```lua
local ok, code, message = mod.storage:writeBytes(
game, "cache/maps/pallet/terrain", encodedMesh)
local encodedMesh, code, message = mod.storage:readBytes(
game, "cache/maps/pallet/terrain")
```
Opaque values are limited to 512 MiB per key. The engine stores them without
decoding, compression, or an engine-defined file format, and never executes
them. A consuming mod owns validation of its format, fingerprint, checksum,
and compression metadata. Byte writes are staged and compared byte-for-byte
before replacement, and reads can recover a valid backup after an interrupted
write. Existing table values and opaque byte values use one shared logical key
space; delete a key before changing its value from one type to the other.
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine `context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the private. A title-selected context may additionally contain `normalSavedAt`, the
@@ -316,19 +335,22 @@ progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`. facade for the launcher-selected existing playthrough, or `nil, code, message`.
Resolving this facade is read-only: it never allocates an identity, adopts a Resolving this facade is non-allocating: it never allocates an identity, adopts a
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`, fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same `write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
data-only and transaction contract as `mod.storage`, but remain restricted to `list(prefix)`, and `delete(key)` methods have the same scoped and
the calling mod's selected existing namespace. It is intended for title tools transactional contract as `mod.storage`, but remain restricted to the calling
that need to browse or manage durable history before the first normal SAVE. mod's selected existing namespace. It is intended for title tools that need to
browse or manage durable history before the first normal SAVE.
Values must be tables containing serializable data only. Keys are conservative Table values must contain serializable data only. Opaque values must be Lua
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem strings. Keys are conservative slash-separated segments (letters, digits, `_`,
handles are never exposed. Writes are staged and decode-verified, reads recover `-`); paths and filesystem handles are never exposed. Table writes are staged
from a valid staged/backup generation, and methods return structured errors for and decode-verified; opaque writes are staged and byte-verified; reads recover
normal data or I/O failures. The playthrough identity is allocated lazily on the from a valid staged/backup generation. Methods return structured errors for
first storage/checkpoint call, so an unused API changes no save bytes. normal data, byte validation, and I/O failures. The playthrough identity is
allocated lazily on the first storage/checkpoint call, so an unused API changes
no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state: `mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
+29 -6
View File
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
## The exact API delta ## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage` Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
facade to the calling mod id. Mods receive logical keys and decoded values, never facade to the calling mod id. Mods receive logical keys and either decoded table
filesystem handles or physical paths. values or exact opaque byte strings, never filesystem handles or physical paths.
### Lazy opaque playthrough identity ### Lazy opaque playthrough identity
@@ -75,6 +75,25 @@ Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
then backup data. A valid staged/backup value is returned and promoted then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed. best-effort; corrupt bytes are never executed.
### `mod.storage:writeBytes(game, key, bytes)`
Accepts a Lua string containing opaque bytes and returns `true`, or
`false, code, message`. Empty strings are valid. Payloads are limited to 512
MiB per key. The engine writes the supplied bytes exactly as received, without
decoding, compression, checksums, or an engine-defined envelope. The consuming
mod owns semantic validation of its format.
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
and replacement write is read back and compared byte-for-byte before it is
committed. A failed write leaves the previous verified generation readable.
Byte storage never passes its payload to the Lua serializer, loader, or module
resolver.
Table and byte records share one logical key namespace and a key has one type.
Writing one type over the other returns `type_conflict`; callers must delete the
key before changing its type. `mod.storage:selected(game)` exposes the same
`readBytes` and `writeBytes` operations for the selected playthrough facade.
### `mod.storage:list(game[, prefix])` ### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
@@ -92,9 +111,9 @@ Physical records are scoped as:
`persistence root / mod_storage / game version / playthrough id / mod id` `persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`, Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and `invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
`not_found`. Ordinary data and I/O failures are return values, not callback- `type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
terminating errors. data and I/O failures are return values, not callback-terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently 1,000-process GC stress regression found compiled recursion could intermittently
@@ -107,6 +126,8 @@ boundary.
**Nothing.** No API is removed, no manifest field changes, and no storage path or **Nothing.** No API is removed, no manifest field changes, and no storage path or
playthrough id is created unless a mod invokes `mod.storage` or playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path. `mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
Existing files outside the scoped storage contract are not imported; a caller
must rebuild them through `writeBytes`.
## Parity tests ## Parity tests
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
- **Engine identity:** lazy allocation, save/load preservation, stable legacy - **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation. mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip, - **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
deterministic listing, key rejection, mod/game/playthrough isolation, deterministic listing, key rejection, mod/game/playthrough isolation,
corrupt-main recovery, failure retention, exact delete, and no-mod no-write. corrupt-main recovery, failure retention, selected-playthrough access, exact
delete, and no-mod no-write.
## Deprecation etiquette ## Deprecation etiquette
+6 -1
View File
@@ -1069,7 +1069,8 @@ function Loader:_api(mod)
bucket[key] = value bucket[key] = value
end, end,
}, },
-- Data-only state independent of the vanilla progress checkpoint. The -- Data-only and opaque-byte state independent of the vanilla progress
-- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence; -- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle. -- callers never receive paths or a raw filesystem handle.
storage = { storage = {
@@ -1077,6 +1078,10 @@ function Loader:_api(mod)
selected = function(_, game) return storage:selected(game) end, selected = function(_, game) return storage:selected(game) end,
write = function(_, game, key, value) return storage:write(game, key, value) end, write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) end, read = function(_, game, key) return storage:read(game, key) end,
writeBytes = function(_, game, key, bytes)
return storage:writeBytes(game, key, bytes)
end,
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
list = function(_, game, prefix) return storage:list(game, prefix) end, list = function(_, game, prefix) return storage:list(game, prefix) end,
delete = function(_, game, key) return storage:delete(game, key) end, delete = function(_, game, key) return storage:delete(game, key) end,
}, },
+132 -11
View File
@@ -1,4 +1,5 @@
-- Data-only per-mod persistence, scoped by game version and opaque playthrough. -- 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. -- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
local Storage = {} local Storage = {}
Storage.__index = Storage Storage.__index = Storage
Storage.MAX_BYTES = 512 * 1024 * 1024
local ROOT = "mod_storage" local ROOT = "mod_storage"
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
return data, body return data, body
end 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) function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id") assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage) return setmetatable({ modId = modId, injectedFs = fs }, Storage)
@@ -132,6 +148,10 @@ function Storage:selected(game)
end, end,
read = function(_, key) return self:read(selectedGame, key) end, read = function(_, key) return self:read(selectedGame, key) end,
write = function(_, key, value) return self:write(selectedGame, key, value) 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, list = function(_, prefix) return self:list(selectedGame, prefix) end,
delete = function(_, key) return self:delete(selectedGame, key) end, delete = function(_, key) return self:delete(selectedGame, key) end,
} }
@@ -147,7 +167,7 @@ function Storage:context(game)
} }
end end
function Storage:_names(game, key, allowEmpty) function Storage:_names(game, key, allowEmpty, extension)
if not validKey(key, allowEmpty) then if not validKey(key, allowEmpty) then
return failure("invalid_key", return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") "Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
local scope, code, message = self:_scope(game) local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "") local path = scope.base .. (key ~= "" and ("/" .. key) or "")
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" extension = extension or ".lua"
return scope, path .. extension, path .. extension .. ".bak",
path .. extension .. ".tmp", path
end end
function Storage:write(game, key, value) function Storage:write(game, key, value)
local scope, main, bak, tmp = self:_names(game, key, false) local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end 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 if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables." return false, "encode_failed", "Storage values must be data-only tables."
end end
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
.. tostring(encoded) .. tostring(encoded)
end end
local fs = scope.fs
ensureParent(fs, main) ensureParent(fs, main)
local _, previous = decodeAt(fs, main) local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end if not previous then _, previous = decodeAt(fs, bak) end
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
end end
function Storage:read(game, key) function Storage:read(game, key)
local scope, main, bak, tmp = self:_names(game, key, false) local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return nil, main, bak end if not scope then return nil, main, bak end
local fs = scope.fs 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) local data, body = decodeAt(fs, main)
if data then return data end if data then return data end
@@ -226,6 +256,80 @@ function Storage:read(game, key)
return data return data
end 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) function Storage:list(game, prefix)
prefix = prefix or "" prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true) local scope, main, codeOrBak = self:_names(game, prefix, true)
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
local base = scope.base local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix) local start = prefix == "" and base or (base .. "/" .. prefix)
local out = {} 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 function walk(path, logical)
local info = fs.getInfo(path) local info = fs.getInfo(path)
if not info then return end if not info then return end
if info.type == "file" then if info.type == "file" then
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end local suffix = path:sub(-4)
if suffix == ".lua" or suffix == ".bin" then
add(logical:sub(1, -5))
end
return return
end end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
-- A prefix may identify one exact key or a directory of keys. -- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix add(prefix)
elseif fs.getInfo(start .. ".bin") then
add(prefix)
else else
walk(start, prefix) walk(start, prefix)
end end
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
end end
function Storage:delete(game, key) function Storage:delete(game, key)
local scope, main, bak, tmp = self:_names(game, key, false) local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end if not scope then return false, main, bak end
local fs = scope.fs local fs = scope.fs
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then 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." return false, "not_found", "No stored value exists for this key."
end end
remove(fs, main) remove(fs, main)
remove(fs, bak) remove(fs, bak)
remove(fs, tmp) remove(fs, tmp)
remove(fs, byteMain)
remove(fs, byteBak)
remove(fs, byteTmp)
return true return true
end end
+110 -2
View File
@@ -7,6 +7,7 @@ love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod storage") local T = require("tests.harness").suite("mod storage")
local Loader = require("src.mods.Loader") local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Storage = require("src.mods.Storage")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks local savedEvents, savedHooks = Runtime.events, Runtime.hooks
@@ -22,7 +23,9 @@ local function memfs(files)
function fs.read(path) return files[path] end function fs.read(path) return files[path] end
function fs.write(path, body) function fs.write(path, body)
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then
return false, "main denied"
end
files[path] = body files[path] = body
return true return true
end end
@@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data")
T.check(loaded ~= payload and loaded.nested ~= payload.nested, T.check(loaded ~= payload and loaded.nested ~= payload.nested,
"read returns decoded data rather than the caller's live table") "read returns decoded data rather than the caller's live table")
T.check(type(alpha.writeBytes) == "function"
and type(alpha.readBytes) == "function",
"mod.storage exposes opaque byte read/write methods")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true"
local binaryOk, binaryCode, binaryMessage =
alpha:writeBytes(current, "states/quick/blob", binary)
T.check(binaryOk == true,
"opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage))
local binaryLoaded, binaryReadCode =
alpha:readBytes(current, "states/quick/blob")
T.eq(binaryLoaded, binary,
"opaque bytes round-trip without text or Lua decoding")
T.eq(binaryReadCode, nil, "successful opaque byte read has no error")
T.eq(_G.MOD_STORAGE_EXECUTED, nil,
"Lua-looking opaque bytes are never executed")
local emptyOk = alpha:writeBytes(current, "binary/empty", "")
T.check(emptyOk == true, "empty opaque byte payloads are valid")
T.eq(alpha:readBytes(current, "binary/empty"), "",
"empty opaque byte payloads round-trip")
local badBytes, badBytesCode =
alpha:writeBytes(current, "binary/bad-type", { byte = true })
T.check(not badBytes and badBytesCode == "invalid_bytes",
"non-string opaque payloads are rejected")
local savedLimit = Storage.MAX_BYTES
Storage.MAX_BYTES = 4
local tooLarge, tooLargeCode =
alpha:writeBytes(current, "binary/too-large", "12345")
Storage.MAX_BYTES = savedLimit
T.check(not tooLarge and tooLargeCode == "size_limit",
"opaque payloads over the per-key limit are rejected")
local tableConflict, tableConflictCode =
alpha:writeBytes(current, "states/quick/q1", "table-key-conflict")
T.check(not tableConflict and tableConflictCode == "type_conflict",
"bytes cannot replace a table record without deletion")
local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob")
T.check(wrongType == nil and wrongTypeCode == "type_mismatch",
"table reads identify byte records as the wrong storage type")
end
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
T.check(not bad and badCode == "encode_failed", T.check(not bad and badCode == "encode_failed",
"functions are rejected with a stable data-only error") "functions are rejected with a stable data-only error")
@@ -120,19 +167,33 @@ T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
T.check(alpha:write(current, "settings", { enabled = true }), "write settings") T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
local keys = alpha:list(current, "states/quick") local keys = alpha:list(current, "states/quick")
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" }, T.same(keys, { "states/quick/alpha", "states/quick/blob",
"states/quick/q1", "states/quick/zeta" },
"list returns sorted logical keys under the requested prefix") "list returns sorted logical keys under the requested prefix")
-- Mod, playthrough, and game namespaces cannot observe each other. -- Mod, playthrough, and game namespaces cannot observe each other.
local missing, missingCode = beta:read(current, "states/quick/q1") local missing, missingCode = beta:read(current, "states/quick/q1")
T.check(missing == nil and missingCode == "not_found", T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's payload") "another mod cannot read the first mod's payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = beta:readBytes(current, "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's opaque payload")
end
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found", T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the payload") "another playthrough cannot read the payload")
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found", T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the payload") "another game version cannot read the payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the opaque payload")
missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the opaque payload")
end
-- Find the implementation-owned file only to inject corruption; assertions stay -- Find the implementation-owned file only to inject corruption; assertions stay
-- on public read behavior, not the path shape. -- on public read behavior, not the path shape.
@@ -142,6 +203,12 @@ local function mainFor(fragment)
end end
end end
local function byteMainFor(fragment)
for path in pairs(files) do
if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then return path end
end
end
local q1Main = mainFor("q1") local q1Main = mainFor("q1")
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
files[q1Main] = "not a serialized table" files[q1Main] = "not a serialized table"
@@ -158,6 +225,47 @@ T.check(not ok and code == "write_failed", "staging failure is reported")
T.same(alpha:read(current, "replace"), { version = 1 }, T.same(alpha:read(current, "replace"), { version = 1 },
"staging failure leaves the prior value readable") "staging failure leaves the prior value readable")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"),
"seed opaque recovery value")
local recoverMain = byteMainFor("binary/recover")
T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data")
files[recoverMain] = nil
T.eq(alpha:readBytes(current, "binary/recover"), "old-bytes",
"missing opaque main recovers the last verified backup")
T.check(alpha:writeBytes(current, "binary/replace", "version-1"),
"seed opaque replacement value")
fs.failTmp = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-2")
fs.failTmp = false
T.check(not ok and code == "write_failed",
"opaque staging failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque staging failure leaves the prior value readable")
fs.failMain = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-3")
fs.failMain = false
T.check(not ok and code == "write_failed",
"opaque replacement failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque replacement failure leaves the prior value readable")
local byteConflict, byteConflictCode =
alpha:write(current, "binary/replace", { version = 3 })
T.check(not byteConflict and byteConflictCode == "type_conflict",
"tables cannot replace a byte record without deletion")
T.check(alpha:writeBytes(current, "binary/delete", "delete-me"),
"seed opaque delete target")
T.check(alpha:delete(current, "binary/delete") == true,
"delete removes an opaque record")
missing, missingCode = alpha:readBytes(current, "binary/delete")
T.check(missing == nil and missingCode == "not_found",
"deleted opaque key is unavailable")
end
-- Delete is exact and idempotent-not-found is explicit. -- Delete is exact and idempotent-not-found is explicit.
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
@@ -135,6 +135,17 @@ if type(storage) == "table" then
"title binding supports safe same-namespace durable operations") "title binding supports safe same-namespace durable operations")
T.same(selected:read("history/title-operation"), { allowed = true }, T.same(selected:read("history/title-operation"), { allowed = true },
"title durable operation remains scoped to the selected playthrough") "title durable operation remains scoped to the selected playthrough")
T.check(type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function",
"selected storage exposes opaque byte methods")
if type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function" then
local titleBytes = "TITLE\0\255-cache"
T.check(selected:writeBytes("history/title-bytes", titleBytes) == true,
"title binding writes opaque bytes in the selected namespace")
T.eq(selected:readBytes("history/title-bytes"), titleBytes,
"title binding reads opaque bytes in the selected namespace")
end
end end
T.check(title.save.meta.playthroughId == nil, T.check(title.save.meta.playthroughId == nil,
"opening title history never allocates or adopts a playthrough identity") "opening title history never allocates or adopts a playthrough identity")