5 Concepts Save Model
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00

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).

Choose where state belongs

Use the smallest mechanism that matches how long the information should live:

You need to remember Use It lasts until
Private quest progress, such as a stage or NPC conversation count mod.save that game save is replaced or removed
A player preference such as a difficulty toggle mod.options changed by the player; it survives New Game
A larger, independent per-playthrough record mod.storage your mod deletes it through the storage API
A restorable engine-state snapshot mod.checkpoints the checkpoint is replaced or deleted

Most quest mods need mod.save: keep a simple named value in the mod's own namespace rather than in shared story flags. The storage and checkpoint contracts live in The mod object.

save.meta

Every save carries a save.meta block (built by SaveData.buildMeta):

  • format - the save-format number (Version.saveFormat, currently 5). Core migrations run any steps the save's format predates, then stamp the current number. A pre-meta vanilla save becomes a vanilla save of the current format. Core migrations run on Gen 1 saves only; Gold and the Gen 3 games own their save shapes.
  • mods - the { id, version, api } set that was active when the save was written, with modCount.
  • Bookkeeping the engine owns: engine, savedAt, sessionStart, playthroughId, and the cart fields cartId, cartHash, sealBroken. Mods read these only through the documented facades. 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.

modsDiff compares a save to the current mod set for save recovery. It is not a link-compatibility check: player link setup happens in the launcher with a vanilla cart or a sealed custom cart.

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. Per-object state goes under the object id "<mapId>_obj_<index>", the same key the engine uses for save.defeatedTrainers and save.itemsTaken (Map objects and object ids).

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. (field.boot is a Gen 1 surface; see below for what Gold shares.)

Gold

Gold's save is its own implementation (src/core/gen2/Save.lua, driven from src/core/Game2.lua), not a branch inside SaveData: the party struct, the boxes, the phone book and the event bitfield are Gen 2 shapes with their own format number and their own migration ladder. What is shared is deliberate, and it is exactly the mod-facing surface:

  • The file format. Both generations serialize through the same data-only serializer: deterministic key order, rolling backup, staged .tmp write, a save can never execute code. Gold's slots live under saves/gold/ beside the Gen 1 games' own, and orphaned slot files are re-indexed on boot if options.lua loses its slot registry.
  • mod.save. Per-mod state persists under save.modData[modId] on Gold exactly as on Gen 1 (Game2:adoptSave points the loader at the same bucket), and mod.options reads the same version-independent options.lua.
  • The lifecycle. save.created, save.writing, save.loading, save.loaded, the save.write veto and the save.new_game hook all fire at the same moments Gen 1 fires them, with the same payloads.

Gold stamps save.meta through SaveData.buildMeta on every write and runs the same SaveData.runMigrations and SaveData.modsDiff on load, so mod migration chains run once per version bump and save.loaded carries a real modsDiff, exactly as on Gen 1. One divergence remains:

  • Validation is structural, not a content quarantine. Gold's validate scrubs script memory, event bytes, map scenes, player state and mail against what the cart could have written, and logs the report; there is no LOST box on Gold. A species record that changed underneath a save is reconciled from the live data on CONTINUE instead (Mon.syncSaveIdentity).

Gen 3

The Gen 3 games (FireRed, LeafGreen, Ruby, Sapphire, Emerald) run their own save session (src/core/Game3.lua, src/core/game3/), not SaveData's Gen 1 tables. The mod-facing surface is the same as on Gold:

  • mod.save persists under session.modData[modId] (Game3:adoptSave), and mod.options reads the shared options.lua.
  • save.meta is stamped by SaveData.buildMeta when a save is written; mod migration chains and modsDiff run on load before save.loaded.
  • The lifecycle. save.created, save.loading, save.loaded, save.writing, the save.write veto and the save.new_game hook all have Gen 3 call sites. save.new_game is raised from the save schema (src/core/game3/save_schema_firered.lua).
  • No content quarantine. There is no LOST box on Gen 3; a mod that adds species, items or maps must keep them available for as long as a save references them. mod.storage and mod.checkpoints keep their contracts.