# Save System and Migration

`SaveManager` (`scripts/core/SaveManager.gd`, `extends Node`) owns all
persistence: per-run save slots, the profile-holding `config.json`, versioned
migration, and corrupt-slot quarantine. Below: the on-disk layout, the current
save version and its migration steps, the run blob's shape, the config's v2
profile shape, and what lives in `config.json` versus `settings.json`. For the
profile *behavior* (chip, picker filtering, claim/reassign flows) see
[Player Profiles](/docs/underroot/player-profiles).

On Windows the user data directory is
`%APPDATA%\Godot\app_userdata\Underroot\`.

## Save slots

Runs are stored as `user://saves/slot_N.json`. The slot count is
`MAX_SLOTS = 12` (a household picker of 4 cards across, 3 rows); slot index runs
`0..11`. `_get_slot_path(idx)` builds each path:

```gdscript
const SAVE_DIR     := "user://saves/"
static var CONFIG_PATH := "user://config.json"
const SAVE_VERSION := 5
const MAX_SLOTS    := 12
```

> `CONFIG_PATH` is a `static var` rather than a `const` for exactly one reason:
> the headless smoke harness repoints it at a scratch file so a dev gate can
> never write the player's account data. Nothing in the game ever reassigns it.
> See [Verification and CI](/docs/underroot/verification-and-ci#config-isolation--the-harness-never-touches-account-data).

`list_slots()` returns a summary dictionary per existing slot (`idx`,
`player_name`, `days`, `depth`, `blocks_mined`, `discovery_pct`, `generation`,
`is_dead`, `timestamp`, `harrow`, `cosmetics` — the per-slot loadout the picker
portrait renders — and `profile_id`, the owner tag the picker's profile
filtering and owner labels read) and cleans up as it reads: 0-byte files left
by an interrupted pre-atomic save are deleted, and unparseable slots are
skipped.

Writes are atomic. `save_game()` serialises the whole payload **before** touching
the slot file, then writes to `slot_N.json.tmp` and renames it over the real
file — an interrupted or failed write can never truncate a good save to 0 bytes:

```gdscript
var payload := JSON.stringify(_build_save_data())
if payload.is_empty() or payload == "null":
    push_error("[SaveManager] empty save payload — keeping previous save")
    return
var tmp := path + ".tmp"
var file := FileAccess.open(tmp, FileAccess.WRITE)
file.store_string(payload)
file.close()
if FileAccess.file_exists(path):
    DirAccess.remove_absolute(path)
DirAccess.rename_absolute(tmp, path)
```

The save is unindented (it runs every 20 s on the main thread; pretty-printing
adds ~35% to build and write time). Imported saves and account-bundle restores
write with `"\t"` indentation instead.

**Deletion is soft.** `delete_slot()` renames the file to
`slot_N.deleted.json` — a **one-deep trash** (a newer delete of the same slot
clobbers the older trash). Every deletion path funnels through it: the
picker's † tile delete, `abandon_slot()`, `delete_profile()`'s
delete-with-saves, and `reset_active_profile()`. `list_slots()` never sees
trash (it probes exact `slot_N.json` paths). The trash is **never reaped on a
timer** — deliberately, so a deletion noticed weeks later is still
recoverable; it only disappears when the same slot is deleted again or on a
full factory reset. Worst case is 12 files at ~200 KB each.

**Restoring from the trash** (there is deliberately no in-game restore UI yet —
revisit if household use demands it):

1. Close the game (an autosave would fight the edit).
2. In `user://saves/` (Windows: `%APPDATA%\Godot\app_userdata\Underroot\saves\`),
   rename `slot_N.deleted.json` → `slot_N.json`.
3. The slot index is only the filename — if that index is now occupied by a
   newer run, rename to any **free** index instead (`slot_5.json`).
4. If the run belonged to a since-deleted profile, it only shows under the
   picker's "Show all"; use the tile's ⇄ reassign menu to hand it to the right
   player (or it can be claimed as unclaimed after ⇄ → Unclaimed).

## SAVE_VERSION and migration

The current format is `SAVE_VERSION = 5`. Every load routes through `_migrate()`,
which upgrades an older dictionary one step at a time and stamps the result:

```gdscript
func _migrate(data: Dictionary) -> Dictionary:
    var version := int(data.get("version", 1))
    if version < 2:
        # v1 -> v2: modified_tiles moved to compact arrays
        version = 2
    if version < 3:
        # v2 -> v3: the Maw became multi-front (maw_fronts)
        version = 3
    if version < 4:
        # v3 -> v4: cosmetics loadout became per-slot; profile_id reserved
        var run_v: Variant = data.get("run")
        if run_v is Dictionary:
            var run_d := run_v as Dictionary
            if not run_d.get("cosmetics_loadout") is Dictionary:
                run_d["cosmetics_loadout"] = get_cosmetics_loadout()
            if not run_d.has("profile_id"):
                run_d["profile_id"] = ""
        version = 4
    if version < 5:
        # v4 -> v5: skill/class state rides the run blob
        var run5: Variant = data.get("run")
        if run5 is Dictionary:
            var run5d := run5 as Dictionary
            if not run5d.get("skills") is Dictionary:
                run5d["skills"] = {}
            if not run5d.has("class"):
                run5d["class"] = ""
        version = 5
    data["version"] = version
    return data
```

- **v1 to v2** — `modified_tiles` moved from per-tile dictionaries to compact
  arrays: `[x, y]` for a dug tile, `[x, y, mat, hp, max_hp]` for a damaged one.
  Metadata-only bump: `WorldManager.load_from_save` accepts both shapes and
  rewrites compact on the next autosave.
- **v2 to v3** — the Maw became multi-front. Metadata-only bump: `GameManager`
  rebuilds `fronts[0]` from the legacy `maw`/cooldowns when `maw_fronts` is
  absent, then writes `maw_fronts` on the next save.
- **v3 to v4** — the cosmetics loadout became per-slot (`run.cosmetics_loadout`).
  Unlike the earlier steps this one **rewrites data**: it seeds the run's
  loadout from the account template in `config.json` (so an existing save keeps
  its look exactly — the seed must land before `CosmeticManager`'s lazy
  accessor touches the run), and reserves `run.profile_id = ""` for the Player
  Profiles project.
- **v4 to v5** — skills and the champion class joined the run blob for the
  Skill Tree work. Seeds `run.skills = {}` and `run.class = ""` so an existing
  save loads as classless with no skill progress rather than tripping the
  accessors; `SkillManager` fills both from there on.

> To add a format change: bump `SAVE_VERSION` and add a matching
> `if version < N:` block in `_migrate()`. Prefer tolerant readers (accept both
> shapes) over in-place rewrites where possible, following v2/v3.

## The save payload

`_build_save_data()` assembles the top-level dictionary. Alongside `version` and
`player_name`, it gathers each autoload's own `get_save_data()`:

| Top-level key | Source |
|---|---|
| `run` | `GameManager.current_run` (the run blob, below) |
| `inventory` | `Inventory.get_save_data()` |
| `tools` | `ToolState.get_save_data()` |
| `world` | seed, `modified_tiles`, walls, rooted walls, gravestone, `mix_discovered` |
| `forest` | nodes, bushes, pond/boulder state |
| `rocks` | surface rock depletion |
| `maw_adaptation` | `MawAdaptation.get_save_data()` |
| `tasks` / `machines` / `crafting_unlocks` | idle queue, machines, unlock state |
| `survival` / `village` / `projects` | manager save blobs |
| `lineage` / `discovery_pct` / `appearance_seed` / `day_time` | run metadata |

The Maw's payload spans several top-level keys (a legacy layout), so
`MawController.append_save_data(out)` appends directly to the dictionary rather
than nesting one section.

### lineage

`lineage` is `GameManager.lineage` saved wholesale — one entry per fallen
digger, carrying `name`, `gen`, `days`, `depth`, `tiles`, `rituals`,
`cosmetics`, `class`, and `successor_relation`. The four counters are
**snapshots of run-cumulative values** at that digger's death, so a
generation's own contribution is the gap between consecutive entries.

`tiles` / `rituals` / `cosmetics` / `class` were added later and are purely
additive — entries written before them simply lack the keys, and no
`SAVE_VERSION` bump was needed (the same treatment as `villager_deaths`). The
reader tolerates their absence and backfills what it can from the profile's
TOP RUNS rows. See
[GameManager and the Run Lifecycle](/docs/underroot/gamemanager-and-the-run-lifecycle#the-lineage-record).

### The run blob

`run` is `GameManager.current_run` saved wholesale, so any key the run holds
rides along without a version bump. Fields worth knowing (per the architecture
guide and the save code):

| Field | Default on old saves | Meaning |
|---|---|---|
| `is_dead` | derived heuristic | Authoritative death flag (older saves fall back to a food/water/`front_x` check). |
| `days_survived` / `depth_reached` / `blocks_mined` | `0` | Progress counters surfaced in the slot picker. Run-cumulative — they survive a generation death. |
| `generation` | `1` | Lineage generation. |
| `death_look` | absent | Transient: the digger's look and calling at the moment of death, moved into the lineage entry on carry-on and erased. |
| `villager_deaths` | `0` | Graveyard cross count; rides the run blob, no version bump. |
| `tutorial_clock_frozen` | `false` | Two-phase tutorial flag; cleared on any load. |
| `first_task_released` | `false` | First-task gate. |
| `run_uuid` | `""` | Identity for the shared-run lock and async slot writes. Minted once per **run**, not per generation — which is what lets a TOP RUNS row be joined back to a lineage entry by `run_uuid` + `gen`. |
| `challenges` | `[]` | Active challenge ids. |
| `harrow` | `{}` | Artificer's Harrow design (name surfaces in scores). |
| `skills` / `class` | seeded by v5 migration | Per-run skill XP and the chosen champion calling. |
| `black_hollow_cooldown` | `0` | Days until Wren offers another expedition; ticked once per `new_day`. Started only on a successful bank, never on a bust or in story mode. |
| `cosmetics_loadout` | seeded by v4 migration | **Per-slot** equipped cosmetics — the run's look. Reached only via `CosmeticManager._run_loadout()`; `equip()` writes it (and mirrors the account template); `reset_run_equipment()` bares only this copy on a new game. |
| `profile_id` | `""` = unclaimed | Owner profile tag. Stamped with `SaveManager.active_profile_id()` on every new run; an untagged (pre-profile) save is **adopted** by whoever continues it (`GameManager._apply_save_data`; the TitleScreen confirms the claim once several profiles exist). Drives `slots_tagged()`, `delete_profile()`'s delete-with-saves, the reassign menu (`retag_slot`), and the picker's owner filtering. |

Two helpers write the run without rebuilding the whole file.
`update_run_values(values, expected_uuid)` does a load-modify-write of the raw
slot JSON (used by the title screen's dead-slot share); when `expected_uuid` is
set the write is identity-gated on `run.run_uuid` so a late async caller cannot
stamp a slot that has since changed. It must not be called while a run is
hydrated — the next `save_game()` would rebuild the file from `current_run` and
discard the write. `retag_slot(idx, pid)` shares the same raw-write constraint.

## Corrupt-slot quarantine

A slot that fails to parse (or whose root is not a dictionary) is not deleted —
`load_game()` calls `_quarantine_slot()`, which renames it to
`slot_N.corrupt.json` so the player keeps the file for recovery while the game
boots cleanly to an empty slot instead of crash-looping:

```gdscript
func _quarantine_slot(path: String) -> void:
    var backup := path.trim_suffix(".json") + ".corrupt.json"
    if FileAccess.file_exists(backup):
        DirAccess.remove_absolute(backup)
    DirAccess.rename_absolute(path, backup)
    EventBus.show_toast.emit(
        "A save file was damaged and has been set aside as %s." % backup.get_file(),
        Color(0.92, 0.45, 0.20), 8.0)
```

## config.json — the profile store (v2)

`config.json` survives slot deletion and, since `CONFIG_VERSION = 2`, holds
**player profiles**:

```json
{
  "config_version": 2,
  "active_profile": "p1",
  "profiles": { "p1": { "name": "Player 1", "...": "all personal keys" } }
}
```

The only reserved root keys are `config_version`, `active_profile`, and
`profiles`; **every personal key lives inside a profile blob** (unknown keys are
personal-by-default and migrate into the profile too).

**Migration is on read.** `_load_config()` passes every successfully parsed
dict through `_migrate_config()` — a pure, idempotent function that wraps a
flat v1 config (or `{}`) into `profiles.p1` and sanity-repairs a v2 dict
(`active_profile` must exist in `profiles`). All three read paths migrate: the
normal path, the `.tmp`/`.bak` recovery path (migrated *before* the
re-materialising write), and the empty-state fallback, which returns a migrated
skeleton rather than `{}` — a bare `{}` would hand accessors a detached profile
dict whose writes silently vanish. `_save_config()` stays shape-agnostic. The
second entry point for external dicts, `import_account_bundle()`, migrates the
imported config before persisting, so pre-profile backup codes restore
correctly. One caveat: immediately after the first v2 write, `config.json.bak`
still holds the v1 shape — harmless, since any recovery read re-migrates.

**Accessors are profile-scoped.** Every public getter/setter
(`has_played_before()`, `get_cosmetics_owned()`, `has_seen_nudge()`, scores,
codes, harrow designs, lifetime counters, …) reads and writes the **active
profile's blob** through the private `_profile(cfg)` helper, which returns the
blob by reference from an already-migrated dict — the long-standing
read-modify-write pattern (`_load_config()` → mutate → `_save_config(cfg)`)
is unchanged. Public signatures did not change; no call sites moved.

> Because every writer read-modify-writes the **whole file**, two processes
> touching `config.json` at once is a lost-update race, not a merge. That is
> why the smoke harness now sandboxes `CONFIG_PATH` — it used to silently
> revert flags a running game had written.

**Profiles API** (surfaced by the TitleScreen — see
[Player Profiles](/docs/underroot/player-profiles) for the flows):

| Method | Behaviour |
|---|---|
| `active_profile_id()` / `active_profile_name()` | Current profile (`"p1"` / its `name`). |
| `profiles_list()` | `[{id, name}]` for every profile. |
| `set_active_profile(id)` | Validates, persists, emits `EventBus.profile_changed(id)`. CosmeticManager reloads on that signal; the TitleScreen does a full scene reload. |
| `create_profile(name)` | Cap `MAX_PROFILES = 8`, names trimmed to 16 chars; returns the new id or `""`. |
| `rename_profile(id, name)` | Renames; same name rules. |
| `delete_profile(id)` | **Deletes the profile AND its tagged save slots** (design ruling: deleting your profile means deleting your data — the slots land in the one-deep trash). Refuses the active profile and the last remaining one. Untagged saves are never touched. Gated behind a typed-name confirmation listing the doomed runs. |
| `slots_tagged(id)` | Slot indices owned by a profile — the delete confirmation lists these. |
| `retag_slot(idx, pid)` | Rewrites a slot's owner tag in place (`""` = back to unclaimed; unknown profiles refused). Raw file write — picker-time only, never while a run is hydrated. |
| `reset_active_profile()` | The gentle reset scope: deletes the active profile's tagged saves (via the trash) and resets its blob to name-only. Other profiles, unclaimed slots, and `settings.json` untouched. |

The write/read hardening predates profiles and is unchanged:

- **Write with rotation** — `_save_config()` builds the payload to
  `config.json.tmp`, retires the previous `config.json` to `config.json.bak`,
  then moves the temp into place. A kill at any step leaves either an intact
  config or a recoverable `.tmp`/`.bak` pair.
- **Read with recovery** — a `config.json` that exists but fails to parse is
  quarantined as `config.corrupt.json`; the load falls back to `.tmp` then
  `.bak`, and only re-materialises `config.json` when the file is truly gone.

Per-profile keys, grouped:

| Group | Keys |
|---|---|
| Identity | `name` |
| First-run and tips | `played_before`, `tips_enabled`, `tutorial_nudges_seen`, `bell_tip_seen`, `rename_tip_seen`, `default_name_index` |
| Scores and sharing | `scores` (per-player bucketed, capped at `MAX_SCORES = 24`), `shared_runs` (`run_uuid -> public url`) |
| Codes | `redeemed_codes` (one-time codes are once **per profile**) |
| Cosmetics and songs | `cosmetics_owned`, `cosmetics_loadout` (the **new-run template** — the per-run truth rides the save's `run.cosmetics_loadout`), `songbook_unlocked` |
| Challenges | `ritual_seen_once`, `challenges_unlocked`, `challenges_earned` |
| Artificer's Harrow | `harrow_announced`, `harrow_intro_seen`, `harrow_designs` (capped at 12), `harrow_last` |
| Black Hollow | `chronicle_notes` (recovered note ids), `black_hollow_schematics` (0–5), and the `black_hollow` sub-dict: `best` (per-expedition best depth), `bands` (one-time band-reached flags), `story_unlocked` (story-mode route chain), `story_best` (per-expedition best story score) |
| Task and lifetime stats | `tasks_fulfilled`, `tasks_denied`, `total_rituals_fired`, `total_tiles_dug`, `total_discoveries_found`, `total_gold_collected` |

The Black Hollow group is deliberately split by mode: `best`, `bands` and
`black_hollow_schematics` are written only by real expeditions, because a
free-play story dive with no stakes must not move a record — or grant an
endgame unlock — earned under real conditions. `chronicle_notes` is the one key
both modes write. All of it defaults via `.get()`, so no `SAVE_VERSION` or
`CONFIG_VERSION` bump was needed for any of it. See
[Black Hollow](/docs/underroot/black-hollow).

Lifetime counters only ever move forward, written at run-end by
`GameManager._flush_lifetime_stats` (delta-based, never per gameplay event, to
avoid churning the disk) — and they follow the *person*, spanning that
profile's slots and lineages. Scores carry the full ledger payload per run so a
run can be submitted online even after its slot is deleted; `save_score()` runs
on **every** death screen, so each fallen generation leaves a row carrying its
portrait and totals (see the lineage section above).

The smoke test (`tools/smoke_test.gd`) exercises the recovery behaviors —
stranded-`.tmp` recovery, damaged-file fallback to `.bak` with quarantine, and
`.bak` rotation on save — with **flat v1 marker dicts asserted through the
migrated shape**, proving crash-recovery and migration compose. It also covers
the profile core (v1→v2 key placement, idempotency, per-profile isolation,
delete-with-saves, `retag_slot`, `reset_active_profile`, the `profile_id`
slot-summary exposure), the one-deep trash (rename-not-remove, full payload in
the trash file, harness sweeps its own trash), and the v4 slot material
(per-slot loadout round-trip, run-only reset scope, synthetic v3 migration).
All of it now runs against a scratch `user://smoke_config.json`, never the real
file. One hard-won rule for future smoke work: the harness's own scratch save is
stamped with the **real machine's** active profile id, so profile assertions
must not assume disjointness from real ids — the harness retags its own slot
into the scratch world first.

## settings.json

Device preferences live in a separate file, `user://settings.json`, owned by the
settings panel (`scenes/ui/SettingsPanel.gd`) — not by `SaveManager`. It holds:

| Key | Meaning |
|---|---|
| `music`, `music_volume` | Audio preferences. |
| `low_perf_mode`, `perf_suggestion_dismissed`, `erosion_amount` | Performance Mode state. |
| `offline_mult`, `donate_clicked` | Donate-gated offline-speed perk. |
| `hud_style` | HUD skin, `"classic"` or `"talismans"`, machine-wide (deliberately not per-profile — the Legacy UI is transitional). Fresh machines (no stored key, `has_played_before()` false) default to `"talismans"`; machines that predate the skin default to `"classic"`. Flipped by the Settings > "Legacy UI" toggle. |
| `ledger_hold` | TAB-ledger mode: `false` = press-to-toggle (**default**), `true` = hold-to-peek. SettingsPanel writes this key on every save, so a player who has ever saved a setting keeps their explicit choice; the default only reaches those who never chose. |

**One writer, one exception.** SettingsPanel persists every key on each save.
The single exception is `HudShell._persist_style()` (`scripts/ui/HudShell.gd`):
when a fresh machine resolves its default skin, HudShell immediately writes
`hud_style` back with a **merge-write** (re-read file, set only `hud_style`,
write merged dict) so keys SettingsPanel already stored survive. Without that
one-time write, finishing the tutorial (which flips `played_before`) would
silently drop the player back to classic on the next boot.

`settings.json` is deliberately preserved across every reset scope: music
volume, low-perf mode, and the HUD skin choice are device preferences rather
than progress, and `donate_clicked` (the offline-speed perk gate) must survive
so a supporter keeps the perk.

## Reset scopes and portable codes

Settings → Reset Game (`scenes/ui/ResetConfirmModal.gd`, typed-RESET guard,
backup Download/Copy buttons) offers **two scopes** once several profiles
exist, defaulting to the gentler one:

- **Only the active player** — `reset_active_profile()`: deletes their tagged
  save slots (through the one-deep trash) and resets their profile blob to
  name-only. Other profiles, unclaimed slots, and `settings.json` are
  untouched.
- **Everything (all players)** — `factory_reset()`: erases every save slot
  (including `.corrupt`, `.deleted`, and stray `.tmp` files), the legacy
  `user://save.json`, `config.json` **and its recovery copies**
  (`config.json.tmp`, `config.json.bak`, `config.corrupt.json` — otherwise
  `_load_config()` would resurrect the profile from them on the next boot),
  and local telemetry, then clears the active slot.

Solo machines see the single full reset, as before. Both scopes restart at the
intro, and the caller must reload config-derived autoload caches (currently
`CosmeticManager.reload_from_config()`).

Saves are also portable as `UROOT1-` codes: `export_save_code()` DEFLATE-
compresses and base64-wraps a single slot's JSON; `export_account_code()` wraps
every slot plus `config.json` as an `account_bundle` — with config v2 the
bundle therefore snapshots **every profile** automatically. `decode_save_code()`
accepts either a code or a raw JSON paste, and the import path validates shape
before writing (`import_run_data` / `import_account_bundle`; the imported
config is migrated through `_migrate_config()` before `_save_config()`, so old
flat-config bundles restore correctly and the pre-import profile survives one
save as `config.json.bak`). The encoding is portability and light obfuscation,
not tamper protection — anyone can decode and re-encode it.

## Key files

- `scripts/core/SaveManager.gd` — slots (incl. the one-deep trash), migration (`_migrate` for slots, `_migrate_config` for the profile store), config, profiles API, scores, portable codes, both reset scopes.
- `scenes/ui/TitleScreen.gd` — the "Playing as" chip, profile menu, owner-filtered picker, claim/reassign flows, typed delete confirmation.
- `scenes/ui/ResetConfirmModal.gd` — dual-scope typed reset with backup buttons.
- `scenes/ui/SettingsPanel.gd` — owns `settings.json` (device preferences); routes the reset.
- `scripts/ui/HudShell.gd` — the one-time `hud_style` merge-write for fresh machines.
- `scripts/core/GameManager.gd` — owns `current_run` and `lineage`; stamps/adopts `profile_id`; rebuilds `maw_fronts[0]` from legacy saves.

## Related

- [Player Profiles](/docs/underroot/player-profiles)
- [Verification and CI](/docs/underroot/verification-and-ci)
- [Black Hollow](/docs/underroot/black-hollow)
- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [GameManager and the Run Lifecycle](/docs/underroot/gamemanager-and-the-run-lifecycle)
- [The Talismans HUD](/docs/underroot/the-talismans-hud)
- [Idle and Offline Simulation](/docs/underroot/idle-and-offline-simulation)
- [Codes and Redemption](/docs/underroot/codes-and-redemption)