Skip to content
Back to skills

Save Sync

ASecurity

Saves, save states, slots, the four save shapes, ROM attribution and archive hashing. Use for anything touching save or state sync, conflict handling, or per-game memory cards.

  • 2 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 15, 2026
devopsgoapi

Works with

  • api

Security analysis

A100/100

Pro scans all 5 files and shows the line behind each finding

Scanned October 15, 2026

npx -y skills add Spinnich/rommbat --skill save-sync --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Save Sync?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Save Sync
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/spinnich-save-sync/badge)](https://www.skillsdirectory.com/skills/spinnich-save-sync)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: save-sync
description: Saves, save states, slots, the four save shapes, ROM attribution and archive hashing. Use for anything touching save or state sync, conflict handling, or per-game memory cards.
---

# Save sync

RomM's `Save` is **strictly one file**: `file_name`, `file_path`, `file_size_bytes`,
`content_hash` (MD5), `slot`, `emulator`. No directory or multi-file concept exists in the
API. Everything below is squeezed through that.

Grout is thin prior art: `sync/directory_saves.go` marks only `psp`, because Linux
handhelds run a narrow emulator set. RetroBat meets every case.

## Map

This file holds the flush and the shapes. The rest is in topic files beside it, by section:

| Section                                                                                                                                           | File                                 |
| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| [Save states: parse, do not hardcode](states.md#save-states-parse-do-not-hardcode)                                                                | [states.md](states.md)               |
| [Somebody else may be writing to the same directory](other-writers.md#somebody-else-may-be-writing-to-the-same-directory)                         | [other-writers.md](other-writers.md) |
| [The other writer is usually the running emulator](other-writers.md#the-other-writer-is-usually-the-running-emulator)                             | [other-writers.md](other-writers.md) |
| [Other writers on the same slots](other-writers.md#other-writers-on-the-same-slots)                                                               | [other-writers.md](other-writers.md) |
| [Attribution](attribution.md#attribution)                                                                                                         | [attribution.md](attribution.md)     |
| [Hash contents, not the archive](attribution.md#hash-contents-not-the-archive)                                                                    | [attribution.md](attribution.md)     |
| [Protocol rules](protocol.md#protocol-rules)                                                                                                      | [protocol.md](protocol.md)           |
| [`device_id` is bookkeeping, never a filter](protocol.md#device_id-is-bookkeeping-never-a-filter)                                                 | [protocol.md](protocol.md)           |
| [The slot is the key to everything, so a save without one is inert](protocol.md#the-slot-is-the-key-to-everything-so-a-save-without-one-is-inert) | [protocol.md](protocol.md)           |
| [Every path that writes server bytes writes the slot](protocol.md#every-path-that-writes-server-bytes-writes-the-slot)                            | [protocol.md](protocol.md)           |
| [Determinism is what makes replay safe](protocol.md#determinism-is-what-makes-replay-safe)                                                        | [protocol.md](protocol.md)           |

## Where the flush passes live

**One Core service, and both front ends are printers over it.** `Sync/SaveFlushService` composes
`SpoolDrain`, `PlaytimeCorrelator`, `StateScanner`, `SaveScanner`, `OutboxFlush`, `SaveSync` and
`StateSync` and returns a `FlushReport`. What the agent's `flush` keeps is `--quiet`, the conflict block and the exit-code mapping.
**Add a pass to the service, never to a subcommand**, or the gamepad UI silently stops doing it.

Four properties of that pass are rules rather than implementation, and each has a test:

- **The tree lock is taken there and a failed acquire is `FlushState.Skipped`.** An outcome with
  its own sentence, never an exception and never a null report. Two flushes overlap whenever
  somebody runs one beside a sync, and the second exits rather than waiting, because waiting
  would put a process to sleep inside the game-launch path.
- **The local half always runs and only sending needs a link.** A caller that could not
  authenticate passes no connection and still gets the drain, the correlate and both scans.
- **States are scanned before saves** (#64) **and sent last.** The scan order is what lets the
  sidecar attribution route see `local_state`; the send order is because states are the only
  part of the pass nobody has to act on.
- **The connection is a parameter, not something the service opens.** Authenticating reads a
  passphrase off a command line and maps to an exit code Core cannot know. A caller that
  supplies a connection gets that one used for the whole pass, sends included: a screen that
  authenticated once must not have its flush quietly open a second connection to whatever the
  store calls the origin.

`FlushState` distinguishes `Done`, `Skipped`, `LocalOnly`, `NotPaired`, `Unreachable` and
`Partial`. **`Unreachable` is not reached in practice** and that is worth knowing before relying
on it: all three sending passes absorb `RomMUnreachableException` per item and report it, so a
server that goes away mid-flush ends the pass `Partial`. The outer catch is inherited from the
subcommand rather than designed.

**`Partial` means this run failed at something it attempted, and nothing else** (#148). A
session close the server refuses counts, though every transfer landed: a token without
`devices.write` fails it on every flush, and the line names the scope so the repeat has a remedy.
On `saves restore --apply` a row the find could not place does not count, since the run never
attempted it: such rows are mostly a standing property of the library, and on the measured `nes`
install 18 states scoped by core pinned every restore at 7 with `failed 0`. They are printed and
counted beside the result instead. The flush is different on one row and keeps it: an offered
bundled slot with no local unit is `Failed`, because it has a remedy, run the game once.

## The four shapes

| Class | Shape                          | Examples                                                                 | Handling                                          |
| ----- | ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------- |
| A     | One file per game              | RetroArch `.srm`/`.sav`/`.eep`                                           | Direct 1:1. Slot `{emulator}:battery`             |
| B     | Several files per game         | `.srm` + `.rtc`, ScummVM `.s00`-`.s99`                                   | Per-file slots when small and stable, else bundle |
| C     | Directory per game             | PPSSPP `SAVEDATA/<GAMEID>/`, RPCS3, Cemu, Citra, Wii NAND, MAME `nvram/` | Bundle to one archive                             |
| D     | Container shared by many games | PCSX2 `Mcd001.ps2`, Dreamcast VMU, megacd `4Mbit_cart.brm`, xbox HDD     | Convert to per-game, see below                    |

**The class says how many files move as a unit. It does not say how the key matches a name**,
and those are two axes, not one. A `unit_paths` entry carries `key` (`title_id`, `hex_ascii`,
`game code`, `rom stem`) saying what it keys on, and does not say whether the match is
**exact or a prefix**. PSP is the case that shows why it matters: `ULUS10064` is a prefix, so
`ULUS10064SYSDATA` belongs to the same unit, and the bundling only works because it was written
knowing that. A platform added later is where the omission bites. Argosy splits the same problem
into five explicit usages (`FOLDER_EXACT`, `FOLDER_PREFIX`, `FILE_EXACT`, `FILE_PREFIX`,
`FOLDER_SPLIT`), which is a match-rule taxonomy and not a rival to these four classes. When
adding a platform, state the match rule alongside the key.

**A save layout is chosen by `(system, emulator)`, never emulator alone.** RetroBat makes this
mostly structural, because its tree is `saves/<system>/<emulator>/` and `save_shapes.json` is
keyed by system folder, so `gamecube/dolphin-emu` and `wii/dolphin-emu` are already distinct.
Argosy, whose registry keyed on emulator, shipped two bugs from exactly this: a Wii row showing
GameCube's path, and a shared override key where a GameCube save path silently became the Wii
one. Our `shapes` map holds one class per system with `shape_depends_on_emulator` as the escape
hatch, which is the same relationship built the other way round; treat a multi-emulator system
as needing the per-emulator answer rather than as an exception (RB-253).

## Class D is a configuration problem

PS1 and GameCube are **already per-game in a stock RetroBat** (`duckstation_memcardtype`
defaults to `PerGameTitle`; `dolphin_slotA` defaults to GCI folder), and both should be left
that way. Only PCSX2 defaults to a shared card, and `pcsx2_slot1_memory=game` names the card
after the ROM stem, which makes attribution trivial on a single-disc title. Its **FOLDER** choice
sounds per-game and is not: `Mcdf01.ps2` is one directory every game writes to, split per save by
PS2 serial, so it is declared a shared container and `saves` names it (RB-406).

**A per-game card is restored whether or not the game is converted here**, because it is a
declared container and needs nothing local to place (#336). PCSX2 reads it only under
`ps2["<rom>"].pcsx2_slot1_memory = game`, so `SaveSync.UnconvertedCards` reports each restored card
whose per-game key is not set, on every flush that reaches the end of negotiation, as `SaveSyncOutcome.NeedsConverting` and
`Advisories`. It reads the live `es_settings.cfg`, not `save_conversion`, and converts nothing
itself: that is a setting in the player's own file, and the player's call.

**GameCube can be moved the wrong way, and the menu makes it easy.** `dolphin_slotA` is
labeled **SAVE FORMAT** with two choices: `8`, the GCI folder that is class C, and `1`, one
shared raw `SRAM.<REGION>.raw` that is class D. So GameCube is class C only at the default, and
a user who picked the tidier-sounding option has a shared card the class C scan finds nothing
in, which `saves` names as a shared container. **Slot B is already there**: RetroBat only ever writes `SlotB` when
`dolphin_microphone` is on, so it stays at Dolphin's stock relative default and a 16 MB
`saves/dolphin/User/GC/SRAM.<REGION>.raw` accumulates beside the system folders. No shape covers
that tree, so `save_rules.json` declares the card under `dolphin` and the scanner names it there,
reporting the rest of `saves/dolphin/` as an unknown shape. RB-193, and the same shape of
trap as PCSX2's four menu entries.

**Dolphin's working files are declared not to be saves, never inferred.** Beside the saves,
`dolphin-emu/User/` on `gamecube` and `wii` holds Dolphin's cache, config, logs, the IPL SRAM and
the virtual SD card, and `save_rules.json`'s `not_a_save_paths` names each path so the
unsyncable report does not count them (RB-405). An extension cannot say it, since `.ini` is
config there and could be a save elsewhere. A path covers everything below it, and loading
refuses one that covers a shared container or the fixed part of a class C container. A path at
a wildcard's own depth is not checked, because `GC/SRAM.raw` is a file beside the `GC/*` region
folders and the table cannot say which it is; declaring `GC/USA` would pass and hide a region.

Set these via `es_settings.cfg`, never an emulator INI. See `retrobat-layout`. The per-game
key is `<system>["<rom filename>"].<key>` and the **filename must keep its extension**; a
bare stem is ignored silently and the emulator keeps writing to the shared container.

### Removing a game names a class D container rather than vouching for it

**A shared container has no `rom_id` by definition, so `SaveGuard` cannot answer for it.** The
same is true of a class C unit whose attribution failed and left a null one. When a person
removes a game, the honest behavior is to **name the container and let them decide**, never to
claim safety.

- **Nothing is deleted either way.** Removal walks `local_file`, whose seven kinds hold no
  saves, so the container survives whatever the screen says. What it cannot survive is the
  _attribution_: the ROM going takes with it the only thing that could ever say which game those
  bytes belong to, and that is not recoverable.
- **Scoped to the systems the removed games are in**, via `EvictionService.Unvouchable`. Naming
  every unattributed save on the install would be noise on a screen a person is reading in order
  to press a button.
- This is the hole #110 said it could not close, and it is closed by saying so rather than by
  pretending. A PS2 memory card is the case that exists.

Files in this skill

  • SKILL.md12.4 KB
  • attribution.md29.8 KB
  • other-writers.md21.8 KB
  • protocol.md34.7 KB
  • states.md9.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.