Skip to content

Concepts Save Model

bryanthaboi edited this page Jul 19, 2026 · 1 revision

The save model

What a save records about mods, where a mod keeps its own state, how old saves upgrade, and what happens when a save references content that no longer exists. Implementation: src/core/SaveData.lua, src/core/Game.lua (restoreSave).

save.meta

Every save carries save.meta = { format, mods }:

  • format — the save-format number (Version.saveFormat, currently 2). Core migrations run any steps the save's format predates, then stamp the current number. A pre-meta vanilla save becomes a v2 vanilla save.
  • mods — the { id, version } set that was active when the save was written. On load, SaveData.modsDiff compares it against the active set and produces { added, removed, changed }; a non-empty diff surfaces a one-line notice ("This save was made with 2 mods; 1 is no longer active") and rides the save.loaded payload.

Saves are plain Lua tables serialized with deterministic key order and read back through a data-only parser — a save can never execute code. Every write makes a rolling backup first.

Per-mod state: mod.save and mod.options

Mod state is namespaced, never mixed into engine tables:

mod.save:set("stage", 2)             -- persists under save.modData[modId]
local stage = mod.save:get("stage", 0)

mod.options:define({
  { key = "hard_mode", type = "toggle", label = "HARD MODE", default = false },
  { key = "scale", type = "number", label = "SCALE", default = 1, min = 1, max = 5 },
  { key = "voice", type = "choice", label = "VOICE",
    choices = { { "LOW", "low" }, { "HIGH", "high" } }, default = "low" },
})
local hard = mod.options:get("hard_mode")
  • mod.save reads and writes save.modData[modId] — progress state, saved with the game.
  • mod.options reads options.modOptions[modId] — settings, persisted in options.lua beside the engine's own, surviving New Game. Defined rows render automatically in the mod manager; row types are toggle, choice (choices = { { label, value }, ... }), number (min/max/step), and text (src/mods/ManagerState.lua buildOptionRows). Changing a value emits mod.options_changed ({ mod, key, value }), so a mod can react without polling.

Use mod.save for quest and feature state instead of global flags — flags are a shared namespace, modData is yours alone.

Migrations

When a new mod version changes its own modData shape, register an upgrade step:

mod.migrations:add("1.1.0", function(save)
  local bucket = save.modData[mod.id]
  if bucket and bucket.stage == nil then bucket.stage = 0 end
end)

add(since, fn) records "upgrading from before since runs fn". On load, each active mod's chain runs in semver order against the version the save recorded for that mod, before validation — so a mod repairs its own data instead of watching it get quarantined (SaveData.runMigrations, driven from Game:restoreSave).

Validation and quarantine

After migrations, SaveData.validate walks every content id the save references against the merged data and quarantines unknowns instead of letting them nil-index later:

  • Mons whose species no longer exists move to save.orphaned — the LOST box — with a report row.
  • Items whose id no longer exists are removed with a report row.
  • Locations that no longer exist fall back to the heal point.
  • Reclaim first: quarantined content whose id reappeared (the mod was re-enabled) goes home again — mons through a PC deposit, items through the bag with the PC as overflow.

On a mod-free save every membership test passes and the save comes back untouched. The load report is logged and the diff notice shown; nothing is silently dropped.

Save events and the one save hook

Name Kind Payload / signature Fires
save.created event { save } a fresh save skeleton exists (boot without a save; New Game)
save.writing event { save, meta } before save.lua is written
save.loading event { raw } after parse, before migrations and validation
save.loaded event { save, meta, modsDiff } after validation, save restored
save.new_game hook wrap(next, save) -> save New Game skeleton construction — a total conversion reshapes spawn, party, and money here before anything reads them

The new-game skeleton itself is data-driven: spawn, names, and money come from field.boot (Data Model), so most mods patch that instead of wrapping save.new_game.

Clone this wiki locally