mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Add opaque byte storage to mod API
This commit is contained in:
+33
-11
@@ -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")
|
||||
```
|
||||
|
||||
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
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
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
|
||||
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)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||
`list(prefix)`, and `delete(key)` methods have the same scoped and
|
||||
transactional contract as `mod.storage`, but remain restricted to the calling
|
||||
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
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
Table values must contain serializable data only. Opaque values must be Lua
|
||||
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
|
||||
`-`); paths and filesystem handles are never exposed. Table writes are staged
|
||||
and decode-verified; opaque writes are staged and byte-verified; reads recover
|
||||
from a valid staged/backup generation. Methods return structured errors for
|
||||
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:
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
## The exact API delta
|
||||
|
||||
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
|
||||
filesystem handles or physical paths.
|
||||
facade to the calling mod id. Mods receive logical keys and either decoded table
|
||||
values or exact opaque byte strings, never filesystem handles or physical paths.
|
||||
|
||||
### 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
|
||||
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])`
|
||||
|
||||
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`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
|
||||
`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
|
||||
data and I/O failures are return values, not callback-terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
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
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`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
|
||||
|
||||
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **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,
|
||||
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
|
||||
|
||||
|
||||
+6
-1
@@ -1069,7 +1069,8 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
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;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
@@ -1077,6 +1078,10 @@ function Loader:_api(mod)
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) 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,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
|
||||
+132
-11
@@ -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.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
|
||||
|
||||
local Storage = {}
|
||||
Storage.__index = Storage
|
||||
Storage.MAX_BYTES = 512 * 1024 * 1024
|
||||
|
||||
local ROOT = "mod_storage"
|
||||
|
||||
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
|
||||
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)
|
||||
@@ -132,6 +148,10 @@ function Storage:selected(game)
|
||||
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,
|
||||
}
|
||||
@@ -147,7 +167,7 @@ function Storage:context(game)
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:_names(game, key, allowEmpty)
|
||||
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.")
|
||||
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
|
||||
.. tostring(encoded)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
ensureParent(fs, main)
|
||||
local _, previous = decodeAt(fs, main)
|
||||
if not previous then _, previous = decodeAt(fs, bak) end
|
||||
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -226,6 +256,80 @@ function Storage:read(game, key)
|
||||
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)
|
||||
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
|
||||
|
||||
local base = scope.base
|
||||
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 info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
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
|
||||
end
|
||||
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.
|
||||
if fs.getInfo(start .. ".lua") then
|
||||
out[#out + 1] = prefix
|
||||
add(prefix)
|
||||
elseif fs.getInfo(start .. ".bin") then
|
||||
add(prefix)
|
||||
else
|
||||
walk(start, prefix)
|
||||
end
|
||||
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
|
||||
end
|
||||
|
||||
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
|
||||
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."
|
||||
end
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
remove(fs, byteMain)
|
||||
remove(fs, byteBak)
|
||||
remove(fs, byteTmp)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ love = love or require("tests.love_stub")
|
||||
local T = require("tests.harness").suite("mod storage")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Storage = require("src.mods.Storage")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
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.write(path, body)
|
||||
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
|
||||
return true
|
||||
end
|
||||
@@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data")
|
||||
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
|
||||
"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 })
|
||||
T.check(not bad and badCode == "encode_failed",
|
||||
"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, "settings", { enabled = true }), "write settings")
|
||||
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")
|
||||
|
||||
-- Mod, playthrough, and game namespaces cannot observe each other.
|
||||
local missing, missingCode = beta:read(current, "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"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")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the payload")
|
||||
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"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
|
||||
-- on public read behavior, not the path shape.
|
||||
@@ -142,6 +203,12 @@ local function mainFor(fragment)
|
||||
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")
|
||||
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
|
||||
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 },
|
||||
"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.
|
||||
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
|
||||
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")
|
||||
T.same(selected:read("history/title-operation"), { allowed = true },
|
||||
"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
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"opening title history never allocates or adopts a playthrough identity")
|
||||
|
||||
Reference in New Issue
Block a user