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, withmodCount.- Bookkeeping the engine owns:
engine,savedAt,sessionStart,playthroughId, and the cart fieldscartId,cartHash,sealBroken. Mods read these only through the documented facades. On load,SaveData.modsDiffcompares 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 thesave.loadedpayload.
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.savereads and writessave.modData[modId]- progress state, saved with the game.mod.optionsreadsoptions.modOptions[modId]- settings, persisted inoptions.luabeside the engine's own, surviving New Game. Defined rows render automatically in the mod manager; row types aretoggle,choice(choices = { { label, value }, ... }),number(min/max/step), andtext(src/mods/ManagerState.luabuildOptionRows). Changing a value emitsmod.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
.tmpwrite, a save can never execute code. Gold's slots live undersaves/gold/beside the Gen 1 games' own, and orphaned slot files are re-indexed on boot ifoptions.lualoses its slot registry. mod.save. Per-mod state persists undersave.modData[modId]on Gold exactly as on Gen 1 (Game2:adoptSavepoints the loader at the same bucket), andmod.optionsreads the same version-independentoptions.lua.- The lifecycle.
save.created,save.writing,save.loading,save.loaded, thesave.writeveto and thesave.new_gamehook 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.savepersists undersession.modData[modId](Game3:adoptSave), andmod.optionsreads the sharedoptions.lua.save.metais stamped bySaveData.buildMetawhen a save is written; mod migration chains andmodsDiffrun on load beforesave.loaded.- The lifecycle.
save.created,save.loading,save.loaded,save.writing, thesave.writeveto and thesave.new_gamehook all have Gen 3 call sites.save.new_gameis 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.storageandmod.checkpointskeep their contracts.
Start here
Playing
Tutorials
- Choose a tutorial
- 01 Sprite and Text Tweak
- 02 Balance Patch
- 03 New Species
- 04 New Item and Ball
- 05 New Move
- 06 NPC and Dialogue
- 07 New Map
- 08 Quest
- 09 Custom Music
- 10 New Mechanic
- 11 Custom UI Screen
- 12 Mini Total Conversion
Creative guides
Concepts
Recipes and reference
Author and developer tools
Home · Start Playing · Glossary · Troubleshooting · Repository · Community
Game data is imported locally from a supported ROM supplied by the player. Shared mods contain original work or transform recipes, never extracted game content. Check each guide's target game before using its examples.