diff --git a/docs/modding.md b/docs/modding.md index 0b42ac01..8a1a287c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -322,6 +322,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 @@ -330,19 +349,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: diff --git a/docs/rfcs/0003-playthrough-storage.md b/docs/rfcs/0003-playthrough-storage.md index 96c574e2..e9e4b5c2 100644 --- a/docs/rfcs/0003-playthrough-storage.md +++ b/docs/rfcs/0003-playthrough-storage.md @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 0d017cf6..caf92dee 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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, }, diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index 31456566..d9cb23ab 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -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 diff --git a/tests/modkit/cases/storage.lua b/tests/modkit/cases/storage.lua index a48d0131..626922bc 100644 --- a/tests/modkit/cases/storage.lua +++ b/tests/modkit/cases/storage.lua @@ -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") diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index 850a3da6..9be61c13 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -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")