feat(desktop): add safe explicit Flash save-file persistence

This commit is contained in:
0xrsydn 2026-10-07 08:25:12 +07:00
commit 86f29018fd
17 changed files with 1032 additions and 133 deletions

View file

@ -1,122 +1,175 @@
# Cartridge Flash identification and read subset
# Cartridge Flash and save files
The core supports explicit Macronix Flash selection, chip identification, array reads, and 128 KiB bank selection.
Programming, erase, busy polling, and host save-file persistence remain unimplemented.
This is not working game-save support. Unsupported writes fail instead of reporting a completed save.
The core supports explicit Macronix Flash selection, identification, bank selection, byte programming, and sector/chip erase.
Operations complete after nominal delays and expose bounded data polling. They do not complete immediately on the command write.
The desktop can load and persist raw images through explicit `--save-file PATH` selection.
Original ROM tests verify save/restart/load across processes. Saving in an external game remains unverified.
SRAM, EEPROM, and other manufacturers' protocols remain separate work.
## Selection and initial data
## Selection and core API
The desktop accepts either `--save-type flash64` or `--save-type flash128` in ROM terminal/window modes.
The option is independent of `--rtc`. Omitting it leaves save memory unmapped.
Unknown values and duplicate options fail before opening the ROM.
```sh
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --steps 3000000
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window --frames 60
```
Selection is explicit, not inferred from a filename, ROM signature, or the first write.
A 64 KiB device cannot silently become a 128 KiB device.
The desktop starts each selected device with erased `0xff` bytes and reports the read-only subset's limits.
It does not look for, read, create, or overwrite a save file. Existing game saves remain untouched.
The ROM suite schema still uses no save device by default and has no save-selection field yet.
Use `--save-type flash64` or `--save-type flash128` in terminal or window ROM modes.
Selection is independent of `--rtc`. Omitting the save type leaves save memory unmapped.
No filename, ROM signature, or first-write detection changes the selected device.
Without `--save-file`, the selected device starts erased (`0xff`) and all changes remain volatile.
The ROM suite schema still has no save-selection or save-file field.
Core APIs on `Memory`:
- `set_save_device(SaveDevice)` replaces save hardware and initializes its array to erased bytes.
`None`, `Flash64`, and `Flash128` are available. The call preserves GPIO/RTC state.
- `load_save_image(&[u8])` supplies an exact-capacity image and resets command state, ID mode, and bank selection.
A wrong length or missing device changes nothing. This is host setup, not an emulated program operation.
- `save_image()` inspects the complete array, independently of ID mode or selected bank.
It returns `None` when no save device is selected.
- `set_save_device(SaveDevice)` selects `None`, `Flash64`, or `Flash128` and initializes an erased array.
GPIO/RTC state is preserved.
- `load_save_image(&[u8])` accepts exactly the selected capacity. It resets commands, pending operations, ID mode, bank, and modification tracking.
Invalid size or missing hardware changes nothing.
- `save_image()` inspects the full committed array, independent of bank and ID mode. No selected device returns `None`.
- `save_modified()` reports whether a completed operation changed bytes since setup or loading.
It remains set after host persistence; hosts compare against their last persisted image to avoid redundant writes.
- `save_write_pending()` reports an incomplete command or operation. Hosts must not persist an exit snapshot in this state.
Configuration and image replacement occur between machine steps. No filesystem operations belong to the core.
Selecting GPIO/RTC hardware does not replace an attached save device or its bytes.
This feature can inspect caller-supplied images but cannot modify their data through Flash commands.
Configuration, loading, and host inspection occur between machine steps.
The core has no file, clock, window, or host-audio dependency. Frontends supply image bytes and own durable storage.
A browser frontend can use the same API with browser storage instead of filesystem paths.
## Bus behavior
## Bus and commands
Only `0x0e000000` through `0x0e00ffff` are mapped when a device is selected.
The Flash interface uses byte accesses. Halfword/word transfers, DMA, and instruction fetches remain diagnostic.
Unverified aliases outside this window remain unmapped rather than returning mirrored data.
Ordinary ROM writes remain read-only.
CPU Flash reads must execute from external or internal work RAM, as required by GBATEK's access description.
A read from ROM/BIOS code reports a diagnostic rather than inventing a value for an unsupported bus sequence.
Host byte inspection has no CPU execution restriction. CPU command writes may execute from ROM.
Only `0x0e000000` through `0x0e00ffff` are mapped. Accesses are byte-only.
Halfword/word transfers, DMA, instruction fetches, and unverified aliases remain diagnostic.
CPU Flash reads must execute from external or internal work RAM, following GBATEK's access description.
Reads from ROM/BIOS code remain diagnostic. Host inspection has no execution restriction; CPU writes can execute from ROM.
Ordinary ROM bytes remain read-only.
WAITCNT bits 0–1 select save wait states 4, 3, 2, or 8.
The nominal byte-access costs are therefore 5, 4, 3, or 9 cycles, for both sequential and non-sequential requests.
Save waits do not alter ROM wait fields. Flash data accesses cancel Game Pak opcode prefetch through the existing nominal model.
Changing only save wait bits preserves the queue until a cartridge access cancels it.
These costs are source-backed estimates, not physical timing measurements.
Nominal byte-access costs are therefore 5, 4, 3, or 9 cycles for sequential and non-sequential accesses.
Flash data accesses cancel Game Pak opcode prefetch. Changing save wait bits alone preserves the queue.
These costs are source-backed estimates, not physical measurements.
## Commands
Use these byte addresses and values for the unlock prefix:
The unlock prefix consists of these byte writes:
1. Write `0xaa` to `0x0e005555`.
2. Write `0x55` to `0x0e002aaa`.
3. Write a command byte to `0x0e005555`.
3. Write the command to `0x0e005555`.
Supported commands after the prefix:
| Command | Behavior |
| Command | Behavior after the unlock prefix |
| --- | --- |
| `0x90` | Enter chip-identification mode |
| `0xf0` | Exit identification mode and return to array reads |
| `0xb0` | On Flash128 only, accept a bank byte next at `0x0e000000` |
| `0x90` | Enter chip identification |
| `0xf0` | Exit identification and return to array reads |
| `0xb0` | Flash128 only: next write selects bank 0 or 1 at `0x0e000000` |
| `0xa0` | Next byte write supplies the target address and program data |
| `0x80` | Accept another `0xaa`/`0x55` unlock pair, then an erase confirmation |
In ID mode, byte zero is the manufacturer and byte one is the device:
Erase confirmation is `0x10` at `0x0e005555` for the entire chip, or `0x30` at a 4 KiB sector base.
Sector erase affects only the selected bank's sector. Chip erase affects both banks on Flash128.
Byte programming can clear bits or retain their value. Requests that set a stored zero bit are diagnostic; erase first.
Invalid commands, prefixes, bank values, and sector addresses retain the preceding state for diagnostic retry.
This policy does not reproduce hardware behavior for every malformed sequence.
| Selection | Manufacturer | Device | Combined ID |
| Device | Capacity | Manufacturer byte | Device byte |
| --- | --- | --- | --- |
| Flash64 | `0xc2` | `0x1c` | `0x1cc2` |
| Flash128 | `0xc2` | `0x09` | `0x09c2` |
| Flash64 | 64 KiB | `0xc2` | `0x1c` |
| Flash128 | 128 KiB | `0xc2` | `0x09` |
Other ID-mode addresses remain diagnostic because their values are not established by this subset.
Array mode reads bytes from the selected 64 KiB bank. Flash64 always uses bank zero.
Flash128 accepts bank values zero and one only. Bank changes do not alter stored data.
Entering and leaving ID mode preserves the selected bank.
Reads during an incomplete unlock retain the previous read mode and bank.
Identification exposes these bytes at offsets zero and one. Other ID addresses remain diagnostic.
Bank selection survives ID entry/exit; incomplete unlock reads retain the previous mode and bank.
For the documented 64 KiB Macronix device, a direct `0xf0` write at `0x0e005555` resets command/ID state.
For Flash128, the same write is accepted only as a redundant reset when already idle in array mode.
This bounded no-op matches the inspected emulators' idle behavior. It does not establish direct ID exit or busy-command cancellation.
Flash128 still requires the full unlock prefix to exit ID mode. Direct reset during an incomplete unlock remains diagnostic.
No matching manufacturer datasheet or physical measurement was used to claim broader 128 KiB reset behavior.
For Flash64, direct `0xf0` at `0x0e005555` resets commands/ID state or cancels a busy operation.
Cancellation preserves the old array. Partial physical programming on cancellation is not modeled.
After `0xa0`, `0xf0` at that address is program data, not reset.
For Flash128, direct reset is accepted only as a redundant no-op while idle in array mode.
Flash128 requires an unlocked ID exit; direct busy/partial-command cancellation remains diagnostic.
`0xa0` byte-program setup and `0x80` erase setup remain diagnostic at the command write.
No operation starts, data changes, busy state, completion flag, or interrupt is fabricated.
Unknown commands, invalid prefixes, and invalid bank writes are also diagnostic.
Rejected accesses retain the previous command state and bytes, so retrying the same access gives the same result.
This atomic diagnostic policy does not reproduce hardware handling of every malformed sequence.
## Nominal completion and transactions
## Transactions and validation
Byte programming takes 650 nominal cycles. Sector and chip erase each take 30,000 nominal cycles.
These are simulation constants, not measured Macronix timings. Chip erase duration is deliberately not capacity-dependent.
GBATEK leaves average Macronix operation times unknown. The constants match the pinned mGBA timing approximation below.
No matching manufacturer datasheet or physical timing measurement establishes these values.
The small Flash controller state joins the existing cartridge machine-step transaction.
The full save image stays outside the copied controller state; this read-only subset never mutates it during emulation.
Failed CPU or DMA steps discard speculative controller changes and timing.
Block-store preflight rejects a wide Flash destination before committing earlier RAM or device writes.
HALT/STOP and elapsed cycles cannot create Flash completion events. There is no pending program or erase operation to finish.
While busy, reads are accepted only at the operation's polling address:
Original tests cover both device IDs, every byte in both banks, exact image lengths, erased state, invalid sequences, and unchanged ROM/save data.
They distinguish redundant idle reset from unsupported Flash128 ID/partial-command reset.
ARM/Thumb probes verify ROM command writes, RAM byte reads, wait settings, restrictions, and retryable failed steps.
Additional tests cover staged ID reads, block rollback, DMA rejection, GPIO/RTC independence, and prefetch cancellation.
Desktop tests execute an original IWRAM byte reader with explicit device selection in terminal and native-window modes.
No game code, save file, or copyrighted assets are included in these tests.
- Program: the target byte.
- Sector erase: the selected sector base.
- Chip erase: `0x0e000000`.
## Remaining work
Busy reads return the complement of the final data's bit 7 (DQ7), with other bits zero.
Toggle/error bits, protection, wear, queued sector erase, and suspend/resume are not modeled.
Other busy reads and writes remain diagnostic, except the documented Flash64 cancellation subset.
After completion, ordinary array reads resume. No completion interrupt is generated.
Implement and validate program/erase commands, busy polling, cancellation, and byte-program bit transitions before claiming save functionality.
Then add explicit host save loading and safe persistence, with exact-size validation and restart tests.
Do not overwrite existing files or add immediate-success responses merely to pass a game check.
A program starts at the successful byte-store bus phase, after preceding fetch/data clocks.
The small controller and remaining delay participate in the machine-step transaction.
Completed reads in a step see a staged overlay. The full array changes only when the step commits successfully.
Failed CPU/DMA steps and speculative block-store validation discard staged progress, data changes, and modification flags.
There is no full-image copy per instruction.
`Machine` execution and `Memory::advance_cycles` advance completion. CPU-only stepping does not clock devices automatically.
HALT advances pending operations. STOP freezes them under the current nominal device-clock policy.
This does not reproduce a physical Flash chip's independent oscillator during STOP.
## Desktop persistence
Persistence is explicit. A typical local command is:
```sh
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --save-file roms/pokemon-emerald.sav --window --audio
```
Omit `--audio` for muted execution. This command is not a game-save compatibility claim.
Use a copy of any valuable existing save while compatibility remains unverified.
The save path's parent directory must exist. File extensions do not select the format.
Existing raw images must contain exactly 65,536 or 131,072 bytes, matching the selected hardware.
Loading occurs before the first machine step. A missing file starts an erased array without creating a file immediately.
Directories, symlinks, read-only files, and paths that alias the ROM are rejected.
The ROM is never opened for writing.
Complete the game's own save operation before closing the window normally or pressing Escape.
The frontend persists changed bytes only on a clean exit:
- Interactive window closure or Escape.
- Reaching a requested window frame limit.
- Reaching a terminal step limit or entering terminal STOP.
An incomplete Flash command/operation refuses persistence and returns an error, even if no bytes changed yet.
CPU, DMA, rendering, window, or reported audio errors do not persist the session image.
There is no autosave, crash recovery, save-state support, or RTC persistence.
The emulator cannot detect a game's multi-command save transaction. Exiting between completed commands can still save incomplete game data.
A crash or forced termination loses changes since startup and can leave a stale lock.
The host storage sequence is:
1. Reserve a sibling `.lock` file exclusively for the entire session.
2. Before persistence, compare the current destination bytes with the loaded baseline. Refuse ordinary external changes.
3. Write and sync a uniquely named same-directory `.tmp.<pid>.<counter>` file.
4. Before replacement, write and sync the prior bytes to a unique `.bak.<pid>.<counter>` backup.
5. Recheck the destination, then install the image. Existing files use atomic rename; new files use a no-clobber hard link.
6. Remove the temporary name and sync the parent directory. Release the owned lock on normal scope exit.
Unchanged images create no save or backup. Backups are never overwritten or automatically deleted by this frontend.
Backup files are ordinary files, not OS-enforced immutable files. Review and manage them manually when needed.
A directory-sync failure after installation reports that the save was installed but durability is uncertain.
Storage exhaustion or permission errors are explicit failures; previous backups are retained.
The lock coordinates cooperating emulator processes. It is not protection against hostile or noncooperating filesystem changes.
The final comparison and rename are not an atomic compare-and-swap operation.
After a crash, confirm that no emulator owns the save, inspect the save/backup files, then remove the stale `.lock` manually.
The frontend never takes over an existing lock automatically.
Persistence currently requires a Unix host. Darwin arm64 is validated; Linux and browser storage remain unvalidated.
## Validation and remaining work
Original tests cover all program byte values in both banks, erase scope, exact delays, polling, cancellation, and bit transitions.
ARM/Thumb tests cover store timing, completion reads, failed-step rollback, block preflight, HALT, and STOP.
File tests cover size checks, unchanged images, locks, external changes, aliases, read-only paths, and retained backups.
Original ROM process tests program, restart, reload through an IWRAM byte reader, and verify no persistence after diagnostics.
A native-window test persists on frame-limit exit and reloads in another process.
No game code, user save, firmware, or copyrighted assets are included in the tests.
Next, validate an external game's completed save and load using a disposable save path.
Add independent hardware coverage for timing, status bits, busy addresses, and reset behavior before broadening the model.
Implement SRAM and EEPROM separately when a demonstrated compatibility requirement needs them.
## References
- [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm): capacities, IDs, unlock sequences, bank selection, 64 KiB Macronix reset, byte-only access, and RAM-execution requirements.
- [GBATEK](https://mgba-emu.github.io/gbatek/), WAITCNT section: save wait fields and one-cycle-plus-wait timing.
- [mGBA savedata at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/savedata.c): comparison for controller phases, bank addressing, ID overlays, and idle reset handling. Its default chip IDs differ; its write/erase timing and busy approximation are not copied.
- [ares Flash at 9408cb43](https://github.com/ares-emulator/ares/blob/9408cb43d4948fc3ea6e152a307a34348df3fe04/ares/gba/cartridge/flash.cpp): comparison for IDs, banking, unlocked ID exit, and unchanged idle state after an unrecognized direct reset. Its immediate program/erase behavior and permissive unlock handling are not adopted.
- [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm): capacities, IDs, command sequences, banking, program/erase polling, Macronix reset, byte-only access, and RAM execution.
- [GBATEK](https://mgba-emu.github.io/gbatek/), WAITCNT section: save waits and one-cycle-plus-wait timing.
- [mGBA savedata at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/savedata.c): comparison for controller phases, bank addressing, ID overlays, idle reset, and nominal 650/30,000-cycle delays. Its DQ7 approximation is not hardware evidence. Our array mutation is deferred to transactional completion rather than applied immediately.
- [ares Flash at 9408cb43](https://github.com/ares-emulator/ares/blob/9408cb43d4948fc3ea6e152a307a34348df3fe04/ares/gba/cartridge/flash.cpp): comparison for IDs, banking, unlocked ID exit, and idle reset. Immediate program/erase and permissive unlock handling are not adopted.