From 083cd59bb2dcf3781f234a6614df08eda9fd65d7 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 11 Jun 2026 20:45:51 +0800 Subject: [PATCH] feat(zaparoo): auto-save cartridge SRAM on a timer Periodically pulse the core's "Save Backup RAM" / "Save Memory Card" status bit so dirty SRAM flushes to the mounted save file without the user opening the OSD. The pulse is identical to selecting the OSD menu item, so no new write path to the SD card is introduced. Guard chain before each pulse: conf-string trigger discovered at core load, .fla (N64 FlashRAM) deny, 5s post-mount settle, 1s sector-write quiet window, OSD closed, and the core's own H/D menumask honored via UIO_GET_OSDMASK (masks no-battery games, GBA's write-gated bk_ena, etc). Recovery layers per save: rolling .bak.0..2 generations with atomic tmp+fsync+rename copies, a per-mount .mount anchor, an all-zero/all-FF/ shrink sanity abort, and an external-modification disarm (mtime/size). Saves are tracked per SD slot (N64 mounts several save files; PSX mounts its memcard at slot 2). Off by default; enable with AUTO_SAVE=N seconds in MiSTer.ini. Mechanism credit: Biduleman's SNES_MiSTer_DirectSave (skip-osd-save). Design doc and per-core verification: AUTO_SAVE_PLAN.md. --- AGENTS.md | 13 +- AUTO_SAVE_PLAN.md | 705 ++++++++++++++++++++++++++++++++++ cfg.cpp | 1 + cfg.h | 1 + scheduler.cpp | 2 + support/zaparoo/auto_save.cpp | 551 ++++++++++++++++++++++++++ support/zaparoo/auto_save.h | 6 + user_io.cpp | 4 + 8 files changed, 1282 insertions(+), 1 deletion(-) create mode 100644 AUTO_SAVE_PLAN.md create mode 100644 support/zaparoo/auto_save.cpp create mode 100644 support/zaparoo/auto_save.h diff --git a/AGENTS.md b/AGENTS.md index 4e1d3335c..448d15a6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,18 @@ This repository is a **fork of [MiSTer-devel/Main_MiSTer](https://github.com/MiS The binary cross-compiles for ARM (Cyclone V SoC on the DE10-Nano). There is no native x86 build — do not use the system `gcc`. -**Toolchain** (first time only): `source setup_default_toolchain.sh` — must be sourced, not executed. Downloads gcc-arm 10.2-2020.11 and exports `PATH`/`CC`. +**Preferred build (this fork)**: `./docker-build.sh` — runs `make` inside the devcontainer toolchain image, no host-side toolchain needed. Forwards args to `make`: + +``` +./docker-build.sh # build bin/MiSTer_Zaparoo +./docker-build.sh clean +./docker-build.sh DEBUG=1 V=1 +MISTER_DOCKER_REBUILD=1 ./docker-build.sh # force-rebuild the image +``` + +The script invokes `make PRJ=MiSTer_Zaparoo` so the output binary is named for the fork. Always use this for builds — it's the only path that's reproducible across machines. + +**Bare-metal toolchain** (only if Docker isn't available): `source setup_default_toolchain.sh` — must be sourced, not executed. Downloads gcc-arm 10.2-2020.11 and exports `PATH`/`CC`. Then `make` directly. ``` make # build bin/MiSTer (stripped) and bin/MiSTer.elf diff --git a/AUTO_SAVE_PLAN.md b/AUTO_SAVE_PLAN.md new file mode 100644 index 000000000..36f8817ad --- /dev/null +++ b/AUTO_SAVE_PLAN.md @@ -0,0 +1,705 @@ +# Auto-save cartridge SRAM on a timer (SNES and similar cores) + +> **Revision 2026-06-11**: every per-core claim below re-verified against the +> MiSTer-devel core sources on GitHub. Major changes from the 2026-04-30 draft: +> the HPS-side first-write gate (old guard #7) is **unimplementable** and is +> replaced by the OSD-menumask guard (guard #7 below); the GBA save-type-picker +> hazard no longer exists in the current core; mounted save writes are O_SYNC +> (durability better than assumed); save tracking is per-SD-slot because N64 +> mounts several save files; the "Save NVRAM" label is dropped (no core uses it). + +## Context + +On MiSTer, cores like SNES, NES, Megadrive, NeoGeo, Saturn, Genesis store SRAM +on a "cartridge" file (`.sav`). The HPS does not decide when to flush — the +core does. Today, users must open the OSD (and on some cores explicitly pick +"Save Backup RAM") for the core to flush its dirty SRAM to disk. People +forget, lose progress, and the workaround is folklore. + +We're a Main_MiSTer fork. Goal: add a fork-side feature that periodically +forces the core to flush dirty SRAM, with no upstream-file conflict surface +and no core changes required. + +## How saving actually works (SNES, representative) + +Confirmed by reading SNES.sv (MiSTer-devel/SNES_MiSTer master) and +Main_MiSTer's HPS code: + +```verilog +// SNES.sv +always @(posedge clk_sys) begin + if (bk_ena && ~OSD_STATUS && bk_save_write) + bk_pending <= 1'b1; // SRAM write outside OSD → mark dirty + else if (bk_state | ~bk_ena) + bk_pending <= 1'b0; // cleared after save completes +end + +wire bk_save = status[13] // (A) menu picked + | (bk_pending & OSD_STATUS && status[23]); // (B) autosave +``` + +So the SNES core flushes SRAM when **either**: +- (A) `status[13]` rising edge — what the OSD's "Save Backup RAM" item does, OR +- (B) Autosave on (`status[23]`) AND OSD is open AND `bk_pending` is set. + +The HPS already drives status bits via `user_io_status_set("[13]", 1)` +(user_io.cpp:545). When the core writes a sector to its mounted .sav, +the HPS catches `UIO_SECTOR_WR` (user_io.cpp:3263–3308) and writes it to +disk; the core's `bk_pending` self-clears. So the disk write side is +already handled — we only need to **trigger** it. + +The conf string token for SNES is `D0RD,Save Backup RAM;` — bit 13. Other +cores that expose backup RAM use the same convention with different bit +indices (NES, Megadrive, etc.). All the user normally does is pick that +menu item; the bit toggle is the actual side-effect. + +## Critical files + +| Path | Why it matters | +| --- | --- | +| `user_io.cpp:545` `user_io_status_set` | Already toggles status bits — our only HPS-side primitive needed. | +| `user_io.cpp:2901` `user_io_read_confstr` / `user_io_get_confstr` | Conf string is cached — we can scan it at game-load time. | +| `scheduler.cpp:38` (existing `alt_launcher_poll()` call) | Existing fork hook in the poll coroutine — exactly the pattern we mirror. | +| `support/zaparoo/alt_launcher.cpp` | Existing fork module precedent: `GetTimer`/`CheckTimer`-driven state machine, no upstream conflict surface. | +| `cfg.cpp` / `cfg.h` | The `MiSTer.ini` knob lives here (single appended lines, current fork style — no ifdefs). | +| `menu.cpp:1950-1957` + `spi_uio_cmd16(UIO_GET_OSDMASK, 0)` | The OSD's H/D hide/disable semantics that guard #7 replicates. | + +No upstream `.cpp`/`.h` modification is required beyond a 1-line `#include` ++ 1-line poll call in `scheduler.cpp` (already precedented). + +## Recommended approach + +New module **`support/zaparoo/auto_save.cpp`** + header. One poll call from +`scheduler.cpp` beside the existing `alt_launcher_poll()` call. + +**Behaviour**: + +1. On core boot / ROM load, scan the conf string for a backup-RAM + trigger item. Match by name substring — robust across all + confirmed cores (SNES/NES/MD/SMS/GBA/NeoGeo/TGFX/Saturn/PSX/N64). + Candidates: + - `Save Backup RAM` (SNES, NES, GBA, SMS, MegaDrive, TGFX, Saturn, N64, …) + - `Save Memory Card` (NeoGeo, PSX — `Save Memory Cards` matches via `strstr`) + + ("Save NVRAM" was in the original list; a 2026-06-11 sweep of MiSTer-devel + found no core that uses it — CD-i auto-saves its timekeeper NVRAM + core-side — so it was dropped.) + + Delegate bit extraction to `user_io_status_bits()` so single-hex-char + and `[N]`/`[N:M]` syntaxes both work. Also collect the matched line's + `H`/`h`/`D`/`d` hide/disable prefixes — they drive guard #7. + Cache `(core_name, bit_opt, hd masks)`. + +2. On save-image mount (`auto_save_on_save_mounted(index, path)`): + tracked **per SD slot** (16 slots, mirroring `sd_image[16]`), because N64 + mounts several save files (cart save + controller paks) at different + indexes and one trigger flushes them all. + a. Check the file extension against the **deny-list**: if the path ends + in `.fla` (N64 FlashRAM), disarm the whole core for this mount + generation and log the reason. + b. Otherwise capture the absolute path (`getFullPath` — mount names are + relative to the storage root), take a per-mount anchor snapshot + (`.mount`, see Recovery layers), record the save's mtime/size, + arm the settle timer. + +3. If a trigger bit was found and no mounted save is denied, run a + poll-driven timer (default 60 s, configurable via `MiSTer.ini`), + gated by all seven guards (see Safety considerations): + - Pulse the bit: `user_io_status_set("[N]", 1)` then + `user_io_status_set("[N]", 0)` — back to back, exactly as + menu.cpp:2586 does for the real menu item. No hold time is needed + (each set is a full status-word SPI transfer the core samples), and + a sleep would block both libco cothreads. + - The pulse → core's `bk_save` rising edge → core writes sectors to + .sav via the existing SD-emu path. + - If `bk_pending` is 0 (or the equivalent in-core dirty flag), the + save is a no-op. Cheap. + +4. If no trigger bit was found, or the save extension is on the + deny-list, do nothing — log a one-line reason so we can audit + coverage from the log. + +**Why this beats the alternatives:** + +- **Pulsing OSD_STATUS** (`spi_osd_cmd(OSD_CMD_ENABLE)` then `DISABLE`) + would only work for cores with autosave already enabled, and risks a + visible OSD flash because the OSD framebuffer would render whatever is + cached. Status-bit pulse is invisible. +- **Hardcoded per-core bit table** would work but rots — every new core + with backup RAM needs a code change. Conf-string scanning is + zero-maintenance. +- **External daemon simulating F12** is crude, has visible OSD, and + requires a separate process. +- **Modifying the core** (Biduleman/SNES_MiSTer_DirectSave precedent) is + out of scope — we're a Main_MiSTer fork, not a core fork. + +## Cross-core compatibility (verified by reading core sources) + +All conf-string lines below re-verified 2026-06-11 against MiSTer-devel +GitHub masters (SNES.sv, PSX.sv, N64.sv, Saturn.sv, GBA.sv fetched in full; +others via conf-string dumps and core search): + +| Core | Conf-string SAVE item | Bit | v1 status | +| --- | --- | --- | --- | +| SNES | `D0RC,Load / D0RD,Save Backup RAM; D0ON,Autosave` | 13 | **Supported** — Biduleman's reference platform; `D0` = `~bk_ena` (no-battery games masked, guard #7) | +| NES | `H5D0R7,Save Backup RAM;` | 7 | **Supported** | +| SMS | `H8H9D0R7,Save Backup RAM;` | 7 | **Supported** — chained H8/H9/D0 prefixes all honored by guard #7 | +| Genesis/MegaDrive | `D0RH,Save Backup RAM;` | 17 | **Supported** | +| MegaCD | `D0RH,Save Backup RAM;` | 17 | **Supported** (bonus — mounts save at slot 0 with `pre=1`) | +| NeoGeo | `D4RC,Save Memory Card;` | 12 | **Supported** — `D4` = memcard-enable mask | +| GBA | `D0R[13],Save Backup RAM;` | 13 | **Supported** — `D0` = `~bk_ena`; `bk_ena` only rises once the game writes backup memory or a non-empty save mounts, so guard #7 doubles as a core-accurate first-write gate. SRAM-as-RAM titles are additionally on GBA.sv's hardcoded `sram_quirk` list which forces `bk_ena=0` | +| TurboGrafx16 | `D0R7,Save Backup RAM;` | 7 | **Supported** — same SNES `bk_save` pattern | +| Saturn | `D0R[25],Save Backup RAM;` | 25 | **Supported** — single trigger; core also ignores triggers while `bk_state` is high (core-side re-entrancy guard). Mounts at slot 1 | +| PSX | `RD,Save Memory Cards;` | 13 | **Supported** — `bk_save = status[13]`, rising-edge detected; one 128KB memcard mounted at **slot 2** (`psx.cpp:437`) — per-slot tracking required (the old `use_save` global never covers slot 2) | +| N64 | `R[41],Save Backup RAM; O[42],Autosave,On,Off` | 41 | **Supported except FlashRAM** — mounts **multiple** save files (cart save + controller paks) at incrementing slots (`n64.cpp:440-449`); `bk_save = status[41] \| (OSD_STATUS & ~OSD_STATUS_1 & ~status[42])` | +| 3DO | `D0RD,Save Backup RAM;` | 13 | **Supported** (bonus — slot 0, `pre=1`) | +| Gameboy | `h2RA,Save Backup RAM;` | 10 | **Supported** (bonus — lowercase `h2` = blocked while mask bit 2 *clear*) | +| WonderSwan | `d0rA,Save Backup RAM;` | 42 | **Supported** (bonus — lowercase `r` = ex bit, +32) | +| Pokemon Mini | `d0R[10],Save Backup RAM;` | 10 | **Supported** (bonus) | +| jtcores arcade | `RL,Save Backup RAM;` (JTFRAME_SAVEGAME) | 21 | Matches if the core mounts a save with `pre=1`; otherwise stays inert (fails closed) | +| CD-i | — (auto-saves NVRAM core-side, "NvRAM saved" info) | — | Not needed | + +**v1 label-match list (substring, applied to the conf-string token after +the `R,` prefix):** + +``` +"Save Backup RAM" // SNES, NES, GBA, SMS, MD, MegaCD, TGFX, Saturn, N64, 3DO, GB, WS, ... +"Save Memory Card" // NeoGeo (and PSX — "Save Memory Cards" matches via strstr) +``` + +### File-extension deny-list (replaces the earlier core-name deny-list) + +The only mechanism that needs to refuse a particular ROM is **N64 +FlashRAM** (`.fla`). N64 selects save type from a per-ROM database in +`support/n64/n64.cpp:567-601`; the HPS picks the file extension at +mount time: + +| Extension | Save type | Pulse-safe? | +| --- | --- | --- | +| `.eep` | EEPROM 4K / 16K | Yes | +| `.sra` | SRAM 32K / 96K | Yes | +| `.cpk` / `.tpk` | Controller Pak / Transfer Pak | Yes | +| `.fla` | FlashRAM (erase/program cycles) | **No — deny** | + +The check happens in `auto_save_on_save_mounted(path)`: if the path +ends in `.fla`, disarm the whole core for this mount generation. This means N64 +SRAM/EEPROM/Pak games auto-save normally; FlashRAM games leave it to +the user's manual OSD save. No core-name deny-list needed. + +This generalises: if a future core exposes a non-idempotent save type +under a known file extension, we add one row here. The mechanism +fails open at the file-extension level, which is the right granularity +because that's where save-type lives in MiSTer's HPS data model. + +## Per-core save-type variations (Biduleman feedback) + +Same core, different mechanisms per cartridge type. Pulsing the same +status bit can be safe for one save type and corrupting for another. + +### GBA — "SRAM-as-RAM" hazard, handled core-side + +(2026-06-11 correction: the original draft claimed GBA save type is picked +in the OSD and a mismatch corrupts saves. The current GBA core **auto-detects** +save type — `FLASH1M_V` string scan during ROM copy plus runtime +`save_eeprom/save_sram/save_flash` signals — and there is no save-type menu +item. That hazard no longer exists.) + +The real hazard is **"SRAM-as-RAM"**: a small set of GBA games use the +cart's SRAM region as scratch RAM, not as save data. Pulsing a save for +those would write garbage over the .sav. + +The current GBA core already defends against this, and exposes the defense +to the HPS: + +- `bk_ena <= |save_sz`, and `save_sz` only becomes nonzero when the game + actually writes backup memory, or a non-empty save image mounts. Until + then the save item is masked via its `D0` prefix — a true first-write + gate, computed core-side where SRAM writes are visible. +- Known SRAM-as-RAM titles (Rocky, the DBZ Legacy of Goku series, the + Classic NES / Famicom Mini series, …) are on GBA.sv's hardcoded + `sram_quirk` list, which disables backup RAM entirely → `bk_ena=0` → + item masked. + +Guard #7 (the OSD-menumask guard) honors that mask, so auto-save inherits +exactly the core's own judgement of when saving is legitimate. + +**Why the original "HPS first-write gate" was dropped:** it gated the first +pulse on having observed a `UIO_SECTOR_WR` to the save image — but cores +only write save sectors *when a save is triggered* (OSD item, core autosave +path, or our pulse). In-game SRAM writes are FPGA-internal and never reach +the HPS. The gate could therefore never open for a user who never opens the +OSD — the exact user this feature exists for. Biduleman's `bk_pending` works +because it is a core wire; the HPS-side equivalent is the menumask, not +sector-write observation. + +For SNES-class cores whose `bk_ena` is header-driven rather than +write-gated, the pulse is idempotent anyway: BSRAM holds the content loaded +from the .sav at mount until the game writes it, so an early pulse writes +back what was loaded. + +### N64 — per-save-type idempotency + +N64 has five save types selected by per-ROM database +(`support/n64/n64.cpp:567-601`). Save type → file extension is fixed +at HPS mount time: + +- **EEPROM 4K/16K** (`.eep`) — small, sequential block writes, idempotent. Pulse-safe. +- **SRAM 32K/96K** (`.sra`) — same family as SNES SRAM. Pulse-safe. +- **Controller Pak / Transfer Pak** (`.cpk` / `.tpk`) — separate file, idempotent. Pulse-safe. +- **FlashRAM** (`.fla`) — erase/program cycles. A pulse mid-erase + corrupts the sector. **Not pulse-safe.** + +Mitigation: file-extension deny-list (see Cross-core table above). +Refuse to arm auto-save if the mounted save path ends in `.fla`. +Other extensions go through the standard 7-guard machinery. + +Note: N64's `bk_save = status[41] | (OSD_STATUS & ~OSD_STATUS_1 & ~status[42])` +gates the autosave path on OSD-close (different from SNES's +OSD-open + autosave-on). Doesn't matter for us — we drive the +manual-save path (`status[41]` rising edge), which is the +unconditional `(A)` branch and works identically across all cores. + +### Saturn — single trigger covers both backup targets + +Confirmed via `Saturn.sv`: the core has internal-backup-RAM AND +optional cart-backup-RAM, but both are written to a single mounted +.sav file with the cart-type bit (`status[23:21]`) selecting which +address range is in use. Single trigger (`status[25]`) flushes +whichever target is active. From the HPS side, Saturn looks identical +to SNES — one mount, one bit, idempotent pulse. No special handling. + +### PSX — one memcard, mounted at SD slot 2 + +Confirmed via `PSX.sv` and `support/psx/psx.cpp:431-445`. The verilog +exposes `memcard1_*` and `memcard2_*` interfaces, but `psx_mount_save()` +only mounts ONE 128KB save (memcard 1) — at **SD slot index 2**, not 0. +Trigger is `status[13]`, idempotent, edge-detected, drives the shared +`memcard_save` register. + +This is why the implementation tracks saves per SD slot: the upstream +`use_save` global is only set for slot 0 (plus CD-i and Saturn slot 1, +user_io.cpp:2186), so a `use_save`-gated sector-write hook silently never +arms the in-flight guard for PSX. The hook keys on the write's disk index +against the tracked slot set instead. + +### Future expansion + +Adding a core post-v1 means: (1) confirm its conf-string label appears +in the v1 list, (2) confirm its save-file extension(s) aren't on the +deny-list (and add them if they aren't pulse-safe), (3) test the +seven-guard mechanism on hardware with a known-good ROM, (4) document +any additional per-cartridge hazards. + +## Prior art and attribution + +This feature directly mirrors **Biduleman**'s +[`SNES_MiSTer_DirectSave`](https://github.com/Biduleman/SNES_MiSTer) +fork (branch `skip-osd-save`), which solves the same problem core-side +by removing the OSD_STATUS gate from `bk_save` and adding `bk_load_done` ++ `~bk_state` guards. Our approach replicates those guards HPS-side so +no core-fork is required, but the safety model is theirs and the +README of `auto_save.cpp` will credit Biduleman explicitly. + +Relevant references: +- Biduleman, *SNES_MiSTer_DirectSave* — branch `skip-osd-save`, + `SNES.sv` `bk_save` modification. +- Biduleman's accompanying r/MiSTerFPGA write-up which lays out the + load-race and save-in-flight risks. + +## Safety considerations + +### Honest safety assessment + +Biduleman's own warnings boil down to: power-cut mid-write can corrupt +the .sav (especially on exFAT), and naive autosave can race with +ROM-load or with an in-flight save. He flags these because his fork +runs without an upstream test fleet — not because the technique is +unsound. The actual disk-write path is identical to the manual +"Save Backup RAM" path users already use. We are not introducing a +new code path on the way to the SD card; we're just triggering the +existing one more often. + +What the increased trigger frequency does change: +- **Larger power-cut exposure window** — proportional to writes/hour. + Mitigated by a sane interval (default 60 s, tunable) and by the + no-op behavior when `bk_pending == 0`. +- **More opportunities to race transitions** — fully mitigated by + the HPS-side guards below; failure to implement any one of them + recreates a known corruption mode. +- **No new filesystem-level risks** — exFAT corruption on power-cut + is a generic MiSTer issue independent of this feature. + +Verdict: safe **conditional on** all seven guards below being in place +plus the four-layer recovery write. The guards are the work; the pulse +itself is a one-liner. + +### Naive vs guarded + +The naive "pulse status[N] every 60 s" can corrupt saves in three known ways. +The Biduleman/SNES_MiSTer_DirectSave fork solves these *core-side* by adding +a guard: + +```verilog +// Biduleman fork +wire bk_save = status[13] + | (bk_pending & bk_load_done & ~bk_state & status[23]); +// ^^^^^^^^^^^^ ^^^^^^^^^ +// wait for not currently +// initial load saving +``` + +Since we cannot modify cores, we replicate these guards **HPS-side** before +pulsing the bit. The poll-driven state machine refuses to fire unless **all** +of the following hold: + +1. **A trigger bit was found** for the running core (conf-string scan + succeeded) AND no mounted save's file extension is on the + deny-list (`.fla`). +2. **At least one save image is mounted** (tracked per SD slot via the + `pre` flag of `user_io_file_mount`; an empty-name mount on a tracked + slot is the unmount signal — unmount callers pass `pre=0`, so the + hook must not require `pre` on that path). +3. **At least N seconds have elapsed since ROM-load completion** (settle + window — default 5 s — to let the core finish reading the .sav back into + SRAM before we ask it to write). Lower-bound equivalent of `bk_load_done`. +4. **No save is currently in flight**: the HPS observed no `UIO_SECTOR_WR` + traffic to any tracked save slot in the last M ms (default 1000 ms). + Equivalent of `~bk_state`. (Saturn additionally guards this core-side: + triggers are ignored while `bk_state` is high.) +5. **OSD is closed.** If the OSD is open, the user is interacting; the core + may already be running its own auto-save path (autosave + OSD_STATUS gate + in the original verilog). Skip — we'd only race with it. +6. **The core is not currently loading a ROM**. `is_menu()` is false and no + recent file-mount transition. +7. **The save item is not hidden/disabled by the core's menumask** + (2026-06-11, replaces the unimplementable HPS first-write gate — see the + GBA section). The conf scan collects the matched line's `H/h/D/d` + prefixes; before each pulse, read the live mask via + `spi_uio_cmd16(UIO_GET_OSDMASK, 0)` and skip while uppercase-prefix bits + are set or lowercase-prefix bits are clear — identical semantics to + menu.cpp:1950-1957. This is what makes the pulse exactly as selectable + as the real menu item: `D0` = `~bk_ena` masks no-battery games (SNES + would otherwise write one sector of 0xFF-thrashed BSRAM into a junk + .sav), GBA's write-gated `bk_ena` masks untouched/SRAM-as-RAM carts, + NeoGeo's `D4` masks memcard-disabled states. + +If all conditions hold and the auto-save interval has elapsed, run the +**four-layer recovery write** below, then pulse the trigger bit. + +### Recovery layers (defence in depth) + +A single `.bak` only protects against *one* bad cycle. Silent rolling +corruption (the core writes garbage and we don't notice for several +cycles) defeats it: the next snapshot captures the garbage as the new +`.bak`. The four layers below address that, plus other failure modes. + +**Layer 1 — Rolling generations (`.bak.0` .. `.bak.N-1`, default N=3)** + +Before each pulse, rotate the existing backups and snapshot the current +.sav at index 0: +- delete `.bak.` if present +- `rename(.bak., .bak.)` for i = N-2 .. 0 +- atomic copy of current .sav to `.bak.0` (write to `.bak.0.tmp`, + fsync data, rename, fsync dir) + +This means even if the most recent snapshot captured corruption, the +user can roll back N-1 cycles. With N=3 and a 60 s interval that's +~3 minutes of recoverable history; at minute 4 a corruption-cycle +will still have rotated out of `.bak.2`. Increasing N is cheap (saves +are KB to a few hundred KB) but anchor snapshot (Layer 2) is a better +long-term safety net than just adding more rolling slots. + +**Layer 2 — Per-mount anchor (`.sav.mount`)** + +Taken **once** in `auto_save_on_save_mounted()`, before any pulse fires +this session. Never overwritten until the next mount (next ROM load or +core change). Worst-case recovery anchor: "the save state when you +started playing this ROM." Survives any number of corrupt rolling +cycles. Same atomic write pattern (`.sav.mount.tmp` → fsync → +rename → fsync dir). Skipped if `.sav` doesn't exist yet (first save +of a fresh ROM). + +**Layer 3 — Sanity check before snapshot** + +Before rotating into `.bak.0`, refuse to snapshot if the current .sav +looks corrupt: +- file is all zeros, OR +- file is all 0xFF, OR +- file is dramatically smaller than the previous `.bak.0` + (heuristic: < 50% of previous size and previous was > 1 KB). + +These are corruption signatures, not legitimate save shrinkage. If any +trigger, **skip the rotation and abort the pulse for this cycle** — +better to keep the older known-good rolling chain intact than to +propagate garbage. Log the reason; retry next interval. + +**Layer 4 — External-modification guard** + +If `.sav`'s mtime or size changed since our last write +(or last mount, whichever was later) and we didn't cause it, treat it +as "user imported a save via FTP / SD-card swap." Skip the auto-save +cycle entirely — we shouldn't stomp a save the user just put there. +Re-baseline mtime/size on the next mount. + +### Atomic copy primitive (used by Layer 1 and Layer 2) + +``` +open(dst.tmp, O_WRONLY|O_CREAT|O_TRUNC) +copy bytes from src +fsync(fd_out) // data to platter +close(fd_out) +rename(dst.tmp, dst) // atomic on ext4; delete-then-rename on exFAT/FAT32 +fsync(dirfd) // metadata to platter (open parent O_DIRECTORY|O_RDONLY, fsync, close) +``` + +Note: `rename` is **NOT atomic on exFAT/FAT32** (the typical +/media/fat filesystem). On exFAT/FAT32 the rename is two operations +(delete-then-rename); a power cut between them can leave only `.tmp`. +Recovery procedure includes checking `.tmp` siblings if the target is +missing — `.tmp` is itself a complete copy by construction. + +### After all four layers pass — pulse the trigger bit + +``` +user_io_status_set("[N]", 1); +user_io_status_set("[N]", 0); +``` + +(2026-06-11: the draft held the bit for 10 ms with `usleep`. menu.cpp fires +the real menu item with the two calls back to back — each is a complete +status-word SPI transfer the core samples at clk_sys — and `usleep` would +stall both libco cothreads. The hold was removed.) + +Then update the recorded mtime/size (Layer 4 baseline) so the next +cycle's external-modification guard knows what state we left it in. + +### Failure handling + +If any layer's I/O fails (disk full, permission, IO error, sanity +trigger), abort the pulse — better to skip a save cycle than to +propagate corruption or lose the rolling chain. Log and retry next +interval. + +If `bk_pending` was 0 inside the core, the pulse is a no-op (bk_save +still fires but writes nothing dirty — cheap). If `bk_pending` was 1, +the core walks SRAM, the HPS catches `UIO_SECTOR_WR`, and the .sav is +updated. + +### Power-loss durability of the actual save write + +(2026-06-11 correction: the draft claimed the post-pulse sector writes were +only `fflush`ed and could be lost to a power cut. Wrong — writable save +images are opened with `O_RDWR | O_SYNC` (user_io.cpp:2131), so every +sector the core writes is synchronous to the storage device. The "future +fsync of the mounted fd" follow-up is unnecessary and has been dropped. +The remaining power-cut exposure is the generic exFAT/FAT32 metadata risk, +which the recovery layers cover.) + +### Known race conditions (community-reported) + +- **Save-during-ROM-load**: hot-loading a different ROM while a save was + pending can write the previous game's RAM into the new game's .sav. + Guard #2 + #3 + #6 above prevent this. +- **exFAT corruption on power-cut**: cited in MiSTerFPGA forum threads; + filesystem-level, not specific to this feature. Saving more often + *increases* the cut-power exposure window, but the four recovery + layers mean prior-good saves always survive somewhere + (rolling .bak chain, per-mount anchor, or `.tmp` siblings). Mention + in commit message; not a blocker. +- **Save-while-OSD-open double-fire**: the SNES verilog already triggers + via path (B) when OSD is open + autosave is on. Guard #5 prevents us + from firing concurrently. +- **SRAM-as-RAM games (GBA)**: a few GBA titles use the cart's SRAM + region as scratch RAM. The core keeps `bk_ena=0` for these (quirk list + + write-gated `save_sz`), which masks the save item; guard #7 (menumask) + prevents us from pulsing while it's masked. +- **GBA save-type mismatch**: no longer applicable — the current core + auto-detects save type (no OSD picker exists). +- **FlashRAM erase/program cycle (N64)**: pulses mid-erase corrupt the + sector. Mitigated in v1 by file-extension deny on `.fla` only — N64 + SRAM/EEPROM/Pak ROMs continue to auto-save normally. + +### Recovery procedure + +If a user reports a corrupted .sav, walk the layers from newest to +oldest until one looks good: + +1. **Most recent rolling backup** — `mv .sav.bak.0 .sav`. + Restores state from one auto-save cycle ago. +2. **Older rolling backups** — `mv .sav.bak.1 .sav`, then + `.bak.2`, etc., walking backwards through the rolling chain. +3. **Per-mount anchor** — `mv .sav.mount .sav`. Restores + state from when the user loaded this ROM in the current session. + Survives even when the entire rolling chain has been overwritten + with corruption. +4. **`.tmp` siblings** — on exFAT/FAT32, a power cut between data + write and rename can leave only `.sav.bak.0.tmp` (or + `.sav.mount.tmp`). These are themselves complete copies; rename + them in if the non-tmp file is missing. +5. **Pre-feature .sav** — if all of the above are exhausted, + corruption predates auto-save involvement; out of scope. + +Document this procedure in a short note alongside the feature. + +### Why pulsing status[N] is the right choice (vs. OSD_STATUS pulse) + +Pulsing status[N] uses path (A) in the verilog — the unconditional save +trigger. It does **not** require the user to have enabled the core's +"Autosave" option. Trade-off: we lose the core's `OSD_STATUS` gate and +must replicate it HPS-side. We do (guard #5). + +Pulsing OSD_STATUS (the alternative) would require autosave-on, *and* +flash the OSD framebuffer briefly — visible glitch. Status-bit pulse is +invisible to the user. + +## Configuration (`MiSTer.ini`) + +Single user-facing knob: + +```ini +; Zaparoo fork — cartridge SRAM auto-save (seconds between save attempts; 0 = disabled) +AUTO_SAVE=60 +``` + +`cfg.cpp` / `cfg.h` additions are single appended lines (the fork dropped +its `#ifdef ZAPAROO` guards in the 2026-05 restructure; unconditional +single-line additions are the current house style). + +Hardcoded constants (no INI knob — tuning these in the field is not +the user's job): +- `SETTLE_MS = 5000` — wait after mount before first pulse +- `QUIET_MS = 1000` — quiet window after save-image sector writes +- `BAK_GENERATIONS = 3` — rolling-backup chain length + +## Resolved design choices + +Confirmed with user: +- **OSD policy**: Skip while OSD is open (avoid racing with the core's + own autosave path B in SNES.sv). +- **v1 scope (cores)**: SNES, NES, SMS, MegaDrive, GBA, NeoGeo, TGFX, + Saturn, PSX, N64 (except FlashRAM `.fla`). Selection driven by + conf-string label substring match plus a save-file extension + deny-list (`.fla`). No core-name deny-list. +- **Config**: single `AUTO_SAVE=N` key in `MiSTer.ini` (seconds, `0` = + disabled = default). Plain appended lines in `cfg.{cpp,h}`. +- **Menumask guard (2026-06-11, supersedes the first-write gate)**: the + 2026-04-30 `s_first_write_seen` design was discovered to be + unimplementable (cores only emit save-sector writes when a save is + triggered, so the gate could never open). Replaced by honoring the save + item's H/D hide/disable prefixes against the live `UIO_GET_OSDMASK`, + which carries the core's own `bk_ena`-class gating — including GBA's + write-gated first-write semantics — to the HPS. +- **Recovery layers (added 2026-04-30)**: four-layer defence-in-depth. + - Layer 1: rolling generations `.bak.0` .. `.bak.`, default N=3. + - Layer 2: per-mount anchor `.sav.mount`, taken once per ROM-load. + - Layer 3: sanity check (zeros / 0xFF / drastic shrinkage) refuses + rotation and aborts the pulse. + - Layer 4: external-modification guard via mtime/size compare — + skip if the user changed the file out from under us. +- **Atomic write primitive**: `.tmp` write → `fsync(file)` → + `rename` → `fsync(dirfd)`. Used for both rolling and anchor + snapshots. exFAT/FAT32 rename non-atomicity covered by `.tmp` + recovery in the documented procedure. +- **Pulse-write durability**: resolved 2026-06-11 — mounted save images + are opened `O_RDWR | O_SYNC`, so post-pulse sector writes are already + synchronous. No follow-up needed. +- **Attribution**: Credit Biduleman's `SNES_MiSTer_DirectSave` (branch + `skip-osd-save`) in the new module's header comment and the commit + message. + +## Verification + +No automated test suite exists. Verify on hardware: + +1. Build: `./docker-build.sh` produces `bin/MiSTer_Zaparoo`. Deploy via + `./build.sh`. + +### Core-coverage matrix + +For each of SNES, NES, SMS, MegaDrive, NeoGeo, GBA, TGFX, Saturn, PSX, +and N64 (with a non-FlashRAM ROM): +- Boot core, load a save-capable ROM, save in-game, do **not** open + OSD. Wait past the auto-save interval (default 60 s). +- `stat /media/fat/saves//.sav` — mtime should be recent. +- Confirm `.bak.0` and `.sav.mount` both exist and are non-empty. +- Power-cycle MiSTer, reload the same ROM, confirm save is restored. + +### N64 FlashRAM deny + +Load an N64 FlashRAM-using ROM (e.g. Paper Mario, Zelda: Majora's +Mask). Confirm log shows the file-extension deny on `.fla` and no +pulses fire. Manual OSD save still works. + +### Menumask guard (guard #7) + +- Boot SNES with a ROM that has **no battery RAM** (e.g. a launch-era + action game). The "Save Backup RAM" item is greyed out in the OSD + (`D0` mask set). Wait 2× the interval: confirm the log shows + "save item masked by core" and no `.sav`/`.bak` files appear. +- Boot GBA with a **fresh ROM and no existing .sav**, leave it at the + title screen. `bk_ena` stays low until the game writes backup memory, + so no pulse may fire. Then save in-game, wait one interval, confirm a + pulse fires and the `.sav` appears. +- N64 multi-slot: load an SRAM game with a Controller Pak enabled. + Confirm both save files get `.bak.0` snapshots on a pulse. + +### Recovery-layer tests + +**Layer 1 — rolling generations**: trigger several auto-save cycles in +sequence. After 4 cycles confirm `.bak.0` (most recent), `.bak.1`, +`.bak.2` exist; the 4-cycles-old snapshot has rotated out. Each +should be a valid save loadable by `mv ` and reloading. + +**Layer 2 — per-mount anchor**: load ROM, save in-game, let several +auto-save cycles run. Compare `.sav.mount` to the original .sav +captured before mount — should match the pre-mount state. Recovery +test: `mv .sav.mount .sav`, reload, in-game state should be +the start-of-session snapshot regardless of how many auto-saves have +since rotated. + +**Layer 3 — sanity check**: with the core not running, manually zero +out the .sav file (`dd if=/dev/zero of=.sav bs=1 count=$(stat +-c %s .sav) conv=notrunc`). Boot core, trigger a pulse cycle. +Confirm log shows "sanity check failed, skipping rotation," `.bak.0` +is **not** rotated, and the pulse is aborted. Repeat with all-0xFF +and a truncated file. + +**Layer 4 — external modification**: while a save is mounted and +auto-save is armed, externally modify the .sav (touch with new +mtime, or copy a different file in via FTP). Wait for next interval. +Confirm log shows "external modification detected, skipping" and no +pulse fires until next mount. + +### Race tests + +- Save in-game, immediately load a different ROM before the auto-save + timer fires. Confirm the new ROM's .sav is not clobbered. +- Open OSD, leave it open past the auto-save interval. Confirm we do + not double-fire while the core's own autosave path is active. +- Hot-swap ROMs rapidly. Confirm the settle window prevents writes + before the new save is loaded. + +### Power-loss simulation + +- Pull power mid-pulse (between `.bak.0` rename and the post-pulse + sector writes). Confirm `.bak.0` contains the previous-good state + and `mv` recovery works. +- On exFAT/FAT32: simulate a cut after `.bak.0.tmp` write but before + `rename` completes (e.g. `kill -9` MiSTer mid-cycle if reproducible). + Confirm `mv .sav.bak.0.tmp .sav` recovers a complete save. + +### Negative paths + +- Boot a core *without* backup RAM (e.g. an arcade ROM with no save). + Confirm no spurious SPI traffic, no `.bak`/`.mount` files, log + noise limited to "no save-trigger menu item found". +- Set `auto_save=0`. Confirm no pulses fire and no `.bak`/`.mount` + files are produced. + +### Logging + +With debug logging on, confirm one log line per: successful pulse, +each skipped-guard condition (menumask holds are latched to one line +until the condition clears), sanity-check trigger, external-modification +skip, and FlashRAM deny. Audit `/tmp/MiSTer.log` after a play session +to validate. diff --git a/cfg.cpp b/cfg.cpp index 9c7e08ded..80031af35 100644 --- a/cfg.cpp +++ b/cfg.cpp @@ -146,6 +146,7 @@ static const ini_var_t ini_vars[] = { "XBE2_SHIFT", (void*)(&(cfg.xbe2_shift)), UINT16, 0, 0x22F }, { "SPD_QUIRK", (void*)(&(cfg.spd_quirk)), UINT8, 0, 3 }, { "HDMI_OFF", (void*)(&(cfg.hdmi_off)), UINT16, 0, 1440 }, + { "AUTO_SAVE", (void*)(&(cfg.auto_save)), UINT32, 0, 86400 }, }; static const int nvars = (int)(sizeof(ini_vars) / sizeof(ini_var_t)); diff --git a/cfg.h b/cfg.h index f7873c80a..12322b6d5 100644 --- a/cfg.h +++ b/cfg.h @@ -112,6 +112,7 @@ typedef struct { uint16_t xbe2_shift; uint8_t spd_quirk; uint16_t hdmi_off; + uint32_t auto_save; } cfg_t; extern cfg_t cfg; diff --git a/scheduler.cpp b/scheduler.cpp index 2553e1d58..9643f1b65 100644 --- a/scheduler.cpp +++ b/scheduler.cpp @@ -10,6 +10,7 @@ #include "profiling.h" #include "video.h" #include "support/zaparoo/alt_launcher.h" +#include "support/zaparoo/auto_save.h" static cothread_t co_scheduler = nullptr; static cothread_t co_poll = nullptr; @@ -36,6 +37,7 @@ static void scheduler_co_poll(void) frame_timer(); input_poll(0); alt_launcher_poll(); + auto_save_poll(); video_poll(); } diff --git a/support/zaparoo/auto_save.cpp b/support/zaparoo/auto_save.cpp new file mode 100644 index 000000000..6531343a5 --- /dev/null +++ b/support/zaparoo/auto_save.cpp @@ -0,0 +1,551 @@ +// Periodically pulses a core's "Save Backup RAM" / "Save Memory Card" +// status bit so the core flushes dirty cartridge SRAM to its mounted save +// file without the user having to open the OSD. The pulse is byte-for-byte +// what the OSD menu item does (menu.cpp does user_io_status_set(opt, 1) +// then (opt, 0) back to back), so no new write path to the SD card exists. +// +// Mechanism credit: Biduleman's SNES_MiSTer_DirectSave fork (branch +// skip-osd-save) solves the same problem core-side by removing the +// OSD_STATUS gate from bk_save. We trigger the manual-save status bit +// instead so cores stay unmodified, and replicate the OSD's own safety +// gates HPS-side (see the guard chain in auto_save_poll). +// +// Design doc with per-core verification: AUTO_SAVE_PLAN.md (repo root). + +#include "auto_save.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../cfg.h" +#include "../../file_io.h" +#include "../../hardware.h" +#include "../../spi.h" +#include "../../user_io.h" + +// Settle window after a save-image mount before the first pulse can fire — +// gives the core time to read the existing save back into SRAM first. +static const unsigned long SETTLE_MS = 5000; + +// Quiet window after save-image sector writes. A flush in progress means we +// must not snapshot (torn copy) or re-trigger (the core may ignore or +// restart the flush). +static const unsigned long QUIET_MS = 1000; + +// Rolling backup generations: .bak.0 (newest) .. .bak.N-1 (oldest). +static const int BAK_GENERATIONS = 3; + +// Absolute path of a mounted save image plus room for ".bak.N.tmp". +#define AS_PATH_LEN 1224 +#define AS_NAME_LEN (AS_PATH_LEN + 16) + +// One entry per SD slot (sd_image[16] in user_io.cpp). N64 mounts several +// save files (cart save + controller paks) at different indexes, so a +// single path is not enough. +#define AS_MAX_SLOTS 16 + +struct as_slot_t +{ + bool mounted; + bool denied; // non-pulse-safe save type (.fla) in this slot + bool dirty_by_core; // save-image sector writes seen since last baseline + bool baseline_valid; // mtime/size below are meaningful + time_t mtime; // external-modification baseline (Layer 4) + off_t size; + char path[AS_PATH_LEN]; +}; + +static as_slot_t s_slots[AS_MAX_SLOTS]; + +static char s_core_name[64] = {}; +static bool s_scanned_for_core = false; + +// Matched save-trigger menu item: status bit (as "[N]" for +// user_io_status_set) plus the H/D hide/disable prefixes of that conf line. +// Uppercase H/D block the item while the menumask bit is SET, lowercase +// h/d while it is CLEAR — same semantics as menu.cpp's option parser. +static char s_bit_opt[16] = {}; +static bool s_have_trigger = false; +static uint32_t s_hd_set_mask = 0; +static uint32_t s_hd_clr_mask = 0; + +// Whole-core disarm: external modification detected (Layer 4), cleared by +// the next mount. The .fla deny lives per-slot so it can't outlive its +// mount (N64 remounts saves slot by slot on every ROM load). +static bool s_disarmed = false; + +static unsigned long s_settle_timer = 0; +static unsigned long s_quiet_timer = 0; +static unsigned long s_next_pulse_timer = 0; + +// One-shot log latches for guard conditions that recur every interval. +static bool s_logged_hdmask = false; + +static int hexchar_to_bit(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'V') return c - 'A' + 10; + return -1; +} + +// Walk the option prefixes the same way menu.cpp does: a run of +// H/h/D/d hide/disable tags, then an optional P page +// tag. Collects which menumask bits make the item unselectable. +static const char *skip_option_prefixes(const char *p, uint32_t *set_mask, uint32_t *clr_mask) +{ + while ((p[0] == 'H' || p[0] == 'h' || p[0] == 'D' || p[0] == 'd') && p[1]) + { + int bit = hexchar_to_bit(p[1]); + if (bit < 0) break; + if (p[0] == 'H' || p[0] == 'D') *set_mask |= 1u << bit; + else *clr_mask |= 1u << bit; + p += 2; + } + if (p[0] == 'P' && p[1] >= '0' && p[1] <= '9' && p[2] != ',') p += 2; + return p; +} + +// Walk the bit identifier starting at *p (single hex char or "[N]" / "[N:M]"). +// Returns pointer to the first char after the identifier, or NULL if invalid. +static const char *skip_bit_identifier(const char *p) +{ + if (*p == '[') + { + const char *q = strchr(p, ']'); + return q ? q + 1 : NULL; + } + if ((*p >= '0' && *p <= '9') || (*p >= 'A' && *p <= 'V') || (*p >= 'a' && *p <= 'v')) + return p + 1; + return NULL; +} + +// Parse a save-trigger entry from a single conf-string line. Returns the +// status bit index (0..127) or -1 if this line doesn't match. Delegates the +// bit-number extraction to the upstream parser so we automatically track +// any identifier syntax it understands. +static int parse_save_trigger(const char *line, uint32_t *set_mask, uint32_t *clr_mask) +{ + uint32_t sm = 0, cm = 0; + const char *p = skip_option_prefixes(line, &sm, &cm); + if (*p != 'R' && *p != 'r' && *p != 'T' && *p != 't') return -1; + int ex = (*p == 'r' || *p == 't') ? 1 : 0; + const char *opt = p + 1; + const char *after = skip_bit_identifier(opt); + if (!after || *after != ',') return -1; + + const char *label = after + 1; + static const char *targets[] = { + "Save Backup RAM", + "Save Memory Card", // also matches PSX's "Save Memory Cards" + NULL + }; + bool match = false; + for (int i = 0; targets[i]; i++) + { + if (strstr(label, targets[i])) { match = true; break; } + } + if (!match) return -1; + + int start = 0; + if (!user_io_status_bits(opt, &start, NULL, ex, 1)) return -1; + *set_mask = sm; + *clr_mask = cm; + return start; +} + +static void scan_confstr_for_trigger(void) +{ + s_have_trigger = false; + s_bit_opt[0] = '\0'; + s_hd_set_mask = 0; + s_hd_clr_mask = 0; + + for (int i = 0; ; i++) + { + char *line = user_io_get_confstr(i); + if (!line) break; + int bit = parse_save_trigger(line, &s_hd_set_mask, &s_hd_clr_mask); + if (bit >= 0) + { + snprintf(s_bit_opt, sizeof(s_bit_opt), "[%d]", bit); + s_have_trigger = true; + printf("auto_save: core '%s' trigger bit=%d hd_set=%08X hd_clr=%08X\n", + user_io_get_core_name(), bit, s_hd_set_mask, s_hd_clr_mask); + return; + } + } + printf("auto_save: no save-trigger menu item found for core '%s'\n", + user_io_get_core_name()); +} + +static bool fsync_parent_dir(const char *path) +{ + char dir[AS_NAME_LEN]; + strncpy(dir, path, sizeof(dir) - 1); + dir[sizeof(dir) - 1] = '\0'; + char *slash = strrchr(dir, '/'); + if (!slash || slash == dir) return true; + *slash = '\0'; + + int fd = open(dir, O_RDONLY | O_DIRECTORY); + if (fd < 0) return false; + bool ok = (fsync(fd) == 0); + close(fd); + return ok; +} + +// Atomic copy: write to .tmp, fsync, rename over , fsync the +// directory. rename is not atomic on exFAT/FAT32, but the .tmp is itself a +// complete copy, so a power cut mid-rename still leaves a recoverable file. +static bool copy_file_atomic(const char *src, const char *dst) +{ + char tmp[AS_NAME_LEN + 8]; + if (snprintf(tmp, sizeof(tmp), "%s.tmp", dst) >= (int)sizeof(tmp)) return false; + + int fd_in = open(src, O_RDONLY); + if (fd_in < 0) + { + printf("auto_save: open(%s) failed: %s\n", src, strerror(errno)); + return false; + } + + int fd_out = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd_out < 0) + { + printf("auto_save: open(%s) failed: %s\n", tmp, strerror(errno)); + close(fd_in); + return false; + } + + char buf[8192]; + bool ok = true; + for (;;) + { + ssize_t n = read(fd_in, buf, sizeof(buf)); + if (n == 0) break; + if (n < 0) { ok = false; break; } + ssize_t off = 0; + while (off < n) + { + ssize_t w = write(fd_out, buf + off, (size_t)(n - off)); + if (w <= 0) { ok = false; break; } + off += w; + } + if (!ok) break; + } + + if (ok) ok = (fsync(fd_out) == 0); + close(fd_out); + close(fd_in); + + if (!ok) + { + unlink(tmp); + return false; + } + if (rename(tmp, dst) != 0) + { + printf("auto_save: rename(%s) failed: %s\n", dst, strerror(errno)); + unlink(tmp); + return false; + } + fsync_parent_dir(dst); + return true; +} + +// Layer 1: rolling generations. Drop the oldest, shift the rest, snapshot +// the current save into .bak.0. +static bool rotate_backups(const char *path) +{ + char from[AS_NAME_LEN], to[AS_NAME_LEN]; + + snprintf(to, sizeof(to), "%s.bak.%d", path, BAK_GENERATIONS - 1); + unlink(to); + for (int i = BAK_GENERATIONS - 2; i >= 0; i--) + { + snprintf(from, sizeof(from), "%s.bak.%d", path, i); + snprintf(to, sizeof(to), "%s.bak.%d", path, i + 1); + rename(from, to); // ENOENT is fine: generation not populated yet + } + + snprintf(to, sizeof(to), "%s.bak.0", path); + return copy_file_atomic(path, to); +} + +// Layer 3: refuse to snapshot or pulse when the current save carries a +// corruption signature — better to keep the existing chain intact. +static bool sanity_check_ok(const char *path) +{ + struct stat st; + if (stat(path, &st) != 0) return false; + + char bak0[AS_NAME_LEN]; + snprintf(bak0, sizeof(bak0), "%s.bak.0", path); + struct stat sb; + if (stat(bak0, &sb) == 0 && sb.st_size > 1024 && st.st_size < sb.st_size / 2) + { + printf("auto_save: %s shrank %lld -> %lld bytes, skipping cycle\n", + path, (long long)sb.st_size, (long long)st.st_size); + return false; + } + + if (st.st_size == 0) return true; // nothing to inspect; shrink rule above covers it + + int fd = open(path, O_RDONLY); + if (fd < 0) return false; + + bool all_zero = true, all_ff = true; + char buf[8192]; + for (;;) + { + ssize_t n = read(fd, buf, sizeof(buf)); + if (n == 0) break; + if (n < 0) { close(fd); return false; } + for (ssize_t i = 0; i < n; i++) + { + if (buf[i] != 0x00) all_zero = false; + if (buf[i] != (char)0xFF) all_ff = false; + } + if (!all_zero && !all_ff) break; + } + close(fd); + + if (all_zero || all_ff) + { + printf("auto_save: %s is all-%s, skipping cycle\n", path, all_zero ? "0x00" : "0xFF"); + return false; + } + return true; +} + +static void reset_mount_state(void) +{ + memset(s_slots, 0, sizeof(s_slots)); + s_disarmed = false; + s_settle_timer = 0; + s_quiet_timer = 0; + s_next_pulse_timer = 0; + s_logged_hdmask = false; +} + +static void detect_core_change(void) +{ + const char *current = user_io_get_core_name(); + if (!current) current = ""; + if (strcmp(current, s_core_name) != 0) + { + strncpy(s_core_name, current, sizeof(s_core_name) - 1); + s_core_name[sizeof(s_core_name) - 1] = '\0'; + s_have_trigger = false; + s_scanned_for_core = false; + reset_mount_state(); + } +} + +static bool any_slot_mounted(void) +{ + for (int i = 0; i < AS_MAX_SLOTS; i++) + { + if (s_slots[i].mounted) return true; + } + return false; +} + +static bool any_slot_denied(void) +{ + for (int i = 0; i < AS_MAX_SLOTS; i++) + { + if (s_slots[i].mounted && s_slots[i].denied) return true; + } + return false; +} + +void auto_save_on_save_mounted(unsigned char index, const char *path) +{ + if (cfg.auto_save == 0) return; // disabled: leave no anchor/baseline files + if (index >= AS_MAX_SLOTS || !path || !*path) return; + + // Sync core-change detection first so the poll's reset can't wipe a + // mount that arrives in the same loop iteration as the core switch. + detect_core_change(); + + // Resolve relative-to-storage-root mount names to absolute paths; our + // file I/O below must not depend on the process working directory. + const char *full = getFullPath(path); + as_slot_t *slot = &s_slots[index]; + if (strlen(full) >= sizeof(slot->path)) + { + printf("auto_save: path too long, ignoring slot %d\n", index); + return; + } + + memset(slot, 0, sizeof(*slot)); + strcpy(slot->path, full); + slot->mounted = true; + + // A new mount is a new generation: re-arm after an external-modification + // disarm and reset the recurring-log latches. + s_disarmed = false; + s_logged_hdmask = false; + + size_t len = strlen(slot->path); + if (len > 4 && !strcasecmp(slot->path + len - 4, ".fla")) + { + // N64 FlashRAM uses erase/program cycles; a pulse mid-cycle can + // corrupt a sector. One trigger flushes every mounted save file, + // so a mounted .fla holds off the whole core until it unmounts. + slot->denied = true; + printf("auto_save: %s is FlashRAM (.fla), auto-save disabled for this game\n", slot->path); + return; + } + + struct stat st; + if (stat(slot->path, &st) == 0) + { + // Layer 2: per-mount anchor — the save as it was when this game + // was loaded. Never overwritten until the next mount. + char anchor[AS_NAME_LEN]; + snprintf(anchor, sizeof(anchor), "%s.mount", slot->path); + if (!copy_file_atomic(slot->path, anchor)) + { + printf("auto_save: anchor snapshot of %s failed\n", slot->path); + } + + slot->baseline_valid = true; + slot->mtime = st.st_mtime; + slot->size = st.st_size; + } + // else: fresh game, save will be created on first write. No anchor. + + s_settle_timer = GetTimer(SETTLE_MS); + if (cfg.auto_save) s_next_pulse_timer = GetTimer(cfg.auto_save * 1000UL); +} + +void auto_save_on_save_unmounted(unsigned char index) +{ + if (index >= AS_MAX_SLOTS) return; + if (!s_slots[index].mounted) return; + memset(&s_slots[index], 0, sizeof(s_slots[index])); + if (!any_slot_mounted()) reset_mount_state(); +} + +void auto_save_on_sector_write(int disk) +{ + if (cfg.auto_save == 0) return; + if (disk < 0 || disk >= AS_MAX_SLOTS) return; + if (!s_slots[disk].mounted) return; + s_quiet_timer = GetTimer(QUIET_MS); + s_slots[disk].dirty_by_core = true; +} + +void auto_save_poll(void) +{ + if (cfg.auto_save == 0) return; + + detect_core_change(); + + if (is_menu()) return; + + if (!s_scanned_for_core) + { + scan_confstr_for_trigger(); + s_scanned_for_core = true; + } + if (!s_have_trigger) return; + if (!any_slot_mounted()) return; + if (s_disarmed || any_slot_denied()) return; + if (user_io_osd_is_visible()) return; + + if (s_settle_timer && !CheckTimer(s_settle_timer)) return; + if (s_quiet_timer && !CheckTimer(s_quiet_timer)) return; + + if (!s_next_pulse_timer) + { + s_next_pulse_timer = GetTimer(cfg.auto_save * 1000UL); + return; + } + if (!CheckTimer(s_next_pulse_timer)) return; + + // From here on, every outcome re-arms the interval timer. + s_next_pulse_timer = GetTimer(cfg.auto_save * 1000UL); + + // The OSD greys out / hides the save item via the core's menumask + // (e.g. SNES "D0RD" disables it while bk_ena is low — no battery RAM, + // or GBA before the game first touches backup memory). A user can't + // select a masked item, so neither may we. + if (s_hd_set_mask || s_hd_clr_mask) + { + uint32_t hdmask = spi_uio_cmd16(UIO_GET_OSDMASK, 0); + if ((hdmask & s_hd_set_mask) || (~hdmask & s_hd_clr_mask)) + { + if (!s_logged_hdmask) + { + printf("auto_save: save item masked by core (mask=%04X), holding off\n", hdmask); + s_logged_hdmask = true; + } + return; + } + s_logged_hdmask = false; + } + + // Layer 4: external-modification guard. If a save changed on disk and + // the core didn't write it (FTP import, SD swap), don't stomp it — + // disarm until the next mount. + for (int i = 0; i < AS_MAX_SLOTS; i++) + { + as_slot_t *slot = &s_slots[i]; + if (!slot->mounted) continue; + + struct stat st; + if (stat(slot->path, &st) != 0) continue; // not created yet + + bool changed = !slot->baseline_valid + || st.st_mtime != slot->mtime + || st.st_size != slot->size; + if (changed && !slot->dirty_by_core) + { + printf("auto_save: %s changed externally, disarming until next load\n", slot->path); + s_disarmed = true; + return; + } + slot->baseline_valid = true; + slot->mtime = st.st_mtime; + slot->size = st.st_size; + slot->dirty_by_core = false; + } + + // Layer 3: validate every save before touching any backup chain. + for (int i = 0; i < AS_MAX_SLOTS; i++) + { + as_slot_t *slot = &s_slots[i]; + if (!slot->mounted) continue; + struct stat st; + if (stat(slot->path, &st) != 0) continue; + if (!sanity_check_ok(slot->path)) return; + } + + // Layer 1: rotate the rolling generations and snapshot the current + // state, so the pre-pulse save always survives at least one bad cycle. + for (int i = 0; i < AS_MAX_SLOTS; i++) + { + as_slot_t *slot = &s_slots[i]; + if (!slot->mounted) continue; + struct stat st; + if (stat(slot->path, &st) != 0) continue; + if (!rotate_backups(slot->path)) + { + printf("auto_save: backup rotation of %s failed, skipping pulse\n", slot->path); + return; + } + } + + // All guards passed — pulse the trigger exactly as the OSD menu does. + user_io_status_set(s_bit_opt, 1); + user_io_status_set(s_bit_opt, 0); + printf("auto_save: pulsed %s for core '%s'\n", s_bit_opt, s_core_name); +} diff --git a/support/zaparoo/auto_save.h b/support/zaparoo/auto_save.h new file mode 100644 index 000000000..f397039af --- /dev/null +++ b/support/zaparoo/auto_save.h @@ -0,0 +1,6 @@ +#pragma once + +void auto_save_poll(void); +void auto_save_on_save_mounted(unsigned char index, const char *path); +void auto_save_on_save_unmounted(unsigned char index); +void auto_save_on_sector_write(int disk); diff --git a/user_io.cpp b/user_io.cpp index 61300d83a..0d9d0887d 100644 --- a/user_io.cpp +++ b/user_io.cpp @@ -41,6 +41,7 @@ #include "scaler.h" #include "support.h" #include "support/zaparoo/alt_launcher.h" +#include "support/zaparoo/auto_save.h" #include "support/zaparoo/menu_rbf.h" static char core_path[1024] = {}; @@ -2232,6 +2233,8 @@ int user_io_file_mount(const char *name, unsigned char index, char pre, int pre_ // notify core of possible sd image change spi_uio_cmd8(UIO_SET_SDSTAT, (1 << index) | (writable ? 0 : 0x80)); + if (pre && len) auto_save_on_save_mounted(index, name); + else if (!len) auto_save_on_save_unmounted(index); return ret ? 1 : 0; } @@ -3262,6 +3265,7 @@ void user_io_poll() blks = 1; } DisableIO(); + if (op == 2) auto_save_on_sector_write(disk); if ( sd_type[disk] == SD_TYPE_A2) { //if (op) printf("A2 %x %llu on %d\n", op,lba, disk);