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