113 lines
4.0 KiB
Lua
113 lines
4.0 KiB
Lua
-- A reusable versioned, checksummed save container laid on top of
|
|
-- serialize.lua's plain Lua-expression encoding. serialize.lua only knows how
|
|
-- to turn a value into text and back; it has no notion of "this text might be
|
|
-- truncated, hand-edited, or written by a codebase three versions ago". This
|
|
-- module adds that layer: an envelope carrying a schema version and an
|
|
-- FNV-1a32 checksum of its payload, plus a migration chain that walks old
|
|
-- data forward one version at a time.
|
|
--
|
|
-- A file written before this module existed - a bare Serialize.saveFile dump
|
|
-- with no envelope at all - is treated as implicit version 1, so an existing
|
|
-- save on disk keeps loading instead of being rejected outright.
|
|
--
|
|
-- SaveGame.new(migrations) builds one codec for one call site. `migrations`
|
|
-- is a 1-indexed array where migrations[v] transforms data from version v to
|
|
-- v+1; the codec's CURRENT_VERSION is #migrations + 1. Two call sites with
|
|
-- differently shaped data (a run's game state, a player's meta progression)
|
|
-- each get their own codec with their own migration chain - this module
|
|
-- knows nothing about either shape.
|
|
|
|
local Serialize = require("serialize")
|
|
|
|
local SaveGame = {}
|
|
|
|
local MAGIC = "WARRENSAVE"
|
|
local MASK32 = 0xFFFFFFFF
|
|
|
|
-- FNV-1a, 32-bit. Verified below against the algorithm's published test
|
|
-- vectors (fnv1a32("") and fnv1a32("a")), not just self-consistency.
|
|
function SaveGame.fnv1a32(str)
|
|
local hash = 2166136261
|
|
for i = 1, #str do
|
|
hash = (hash ~ str:byte(i)) & MASK32
|
|
hash = (hash * 16777619) & MASK32
|
|
end
|
|
return hash
|
|
end
|
|
|
|
local function migrateForward(data, fromVersion, migrations, currentVersion)
|
|
for v = fromVersion, currentVersion - 1 do
|
|
data = migrations[v](data)
|
|
end
|
|
return data
|
|
end
|
|
|
|
-- Builds a codec bound to one migration chain. `migrations` may be empty
|
|
-- (CURRENT_VERSION == 1); a version-1-only codec still gets checksumming and
|
|
-- corruption detection for free.
|
|
function SaveGame.new(migrations)
|
|
migrations = migrations or {}
|
|
local currentVersion = #migrations + 1
|
|
local codec = { CURRENT_VERSION = currentVersion }
|
|
|
|
function codec.encode(data)
|
|
local payload = Serialize.encode(data)
|
|
local envelope = {
|
|
magic = MAGIC,
|
|
version = currentVersion,
|
|
checksum = SaveGame.fnv1a32(payload),
|
|
payload = payload,
|
|
}
|
|
return Serialize.encode(envelope)
|
|
end
|
|
|
|
-- Returns (data) on success, or (nil, reason) on any failure. `reason` is
|
|
-- always one of: "missing" (only from .load, never from .decode), a string
|
|
-- starting with "corrupt:", or a string starting with "future version:".
|
|
function codec.decode(text)
|
|
local ok, value = pcall(Serialize.decode, text)
|
|
if not ok or type(value) ~= "table" then
|
|
return nil, "corrupt: cannot parse save data"
|
|
end
|
|
if value.magic ~= MAGIC then
|
|
-- No envelope at all: a save from before this codec existed. The
|
|
-- decoded value IS the raw data, at implicit version 1.
|
|
return migrateForward(value, 1, migrations, currentVersion)
|
|
end
|
|
if type(value.version) ~= "number" or type(value.payload) ~= "string"
|
|
or type(value.checksum) ~= "number" then
|
|
return nil, "corrupt: malformed save envelope"
|
|
end
|
|
if value.version > currentVersion then
|
|
return nil, "future version: save is v" .. value.version
|
|
.. ", this build reads up to v" .. currentVersion
|
|
end
|
|
if SaveGame.fnv1a32(value.payload) ~= value.checksum then
|
|
return nil, "corrupt: checksum mismatch"
|
|
end
|
|
local ok2, data = pcall(Serialize.decode, value.payload)
|
|
if not ok2 or type(data) ~= "table" then
|
|
return nil, "corrupt: cannot parse save payload"
|
|
end
|
|
return migrateForward(data, value.version, migrations, currentVersion)
|
|
end
|
|
|
|
function codec.save(path, data)
|
|
local file = assert(io.open(path, "w"))
|
|
file:write(codec.encode(data))
|
|
file:close()
|
|
end
|
|
|
|
function codec.load(path)
|
|
local file = io.open(path, "r")
|
|
if not file then return nil, "missing" end
|
|
local text = file:read("a")
|
|
file:close()
|
|
return codec.decode(text)
|
|
end
|
|
|
|
return codec
|
|
end
|
|
|
|
return SaveGame
|