Skip to content

Import a .sav file as a save state, and export the cartridge's save as one - #19

Open
macabeus wants to merge 9 commits into
mainfrom
import-sav
Open

Import a .sav file as a save state, and export the cartridge's save as one#19
macabeus wants to merge 9 commits into
mainfrom
import-sav

Conversation

@macabeus

Copy link
Copy Markdown
Owner

A .sav file — the raw battery-backup dump every other GBA emulator and every flash
cart reads and writes — can be brought into gba-kit as a save state, and the save
sitting in the machine right now can be written back out as one.

A ⋯ button beside Save state in the Screen panel's save-state bar opens a menu with
Import from a .sav file and Export to a .sav file. An import is a power-on machine
of this ROM with the file already in its cartridge, at frame 0
: load it, press
continue, and the ROM boots into its own save. That is what VBA-M's
File > Import > Battery file… and mGBA's Load alternate save game both do, and it
makes the result independent of wherever the session happened to be stopped. The import
adds the state and opens the drawer; it does not load it, and the machine being debugged
is not touched — no event is emitted, the revision and epoch do not move, and a session
replaying history keeps replaying.

Over DAP and in-process the requests are gba-kit/importSave and gba-kit/exportSave.
The file dialog is a host capability (pickFile / saveFile on Transport), so VS Code
serves it with showOpenDialog and workspace.fs the way gba-kit.importLabels already
does, and the webapp serves it with <input type="file"> and a download. A menu item
whose host cannot serve it is not shown.

The real import, end to end

kleod.gba declares EEPROM_V121 and nothing else; the retail save is 512 bytes and
begins 4b5f4b4c4f4e4f41, "K_KLONOA". Two boots of 900 frames, one with the save
imported and one without:

declared save type: {"type":"eeprom","id":"EEPROM_V121"}

control     248 EEPROM commands (126 read, 122 write)   screen 61a7f2bb0f92732b
imported     87 EEPROM commands ( 56 read,  31 write)   screen 079f0bc8047a1582

control  cartridge after 900 frames: 0000000000000000
imported cartridge after 900 frames: 4b5f4b4c4f4e4f41 "K_KLONOA"

control  first commands: r@0 w@0 r@0 w@1 r@1 w@2 r@2 w@3 r@3 w@4
imported first commands: r@0 r@1 r@6 r@12 r@16 r@17 r@22 r@28 r@32 w@32

The control finds a blank chip, zeroes the header and formats a fresh save from word 0
upward. The imported run leaves the header standing and goes straight to the words it
wants, then writes the save back rather than replacing it. The two runs end on different
screens. That is the game reading the save, not 512 bytes sitting in an array.

What it refuses, and why

Which chip a file belongs in follows from the ROM's declared save type and the file's
size together. A file those two cannot account for is refused by a message naming
both — never padded or truncated into the wrong chip, where it would silently do nothing:

EEPROM_V121    512:OK  8192:OK   0:no 1:no 511:no 513:no 32768:no 65536:no 131072:no
SRAM_V113      32768:OK                        every other size refused
SRAM_F_V102    32768:OK                        every other size refused
FLASH_V126     every size refused,  export refused
FLASH512_V130  every size refused,  export refused
FLASH1M_V103   every size refused,  export refused
(no declaration) every size refused, export refused

Flash is refused in both directions. gba-kit backs the cartridge with plain memory
and emulates no flash chip, and flash chip emulation is out of scope here — so the
question was whether a flash game can read an imported save anyway. It cannot. Sonic
Advance 1, 2 and 3 each write the identify sequence AA/55/90, read where a chip ID
should be, get the imported save's own bytes back, write the reset command, and never
touch the save again — 0 data reads in 1200 frames — while the command bytes they
wrote land in the save as data. Accepting the file would have been a silent corruption
dressed up as a feature. 1 Mbit flash is doubly out: 128 KB cannot be represented in the
single 64 KB window at all, and bank switching is not emulated either.

An EEPROM that has not said how wide it is cannot be exported. The array is always
8 KB in memory but a 4 Kbit chip's .sav is 512 bytes, so exporting before the game has
read or written its save would be a guess. It is refused with instructions instead: run
the game until it touches its save, or import a .sav first.

Three emulator fixes came with it

  • EEPROM images are now byte-compatible with mGBA, VBA-M and a flash cart. The array
    was byte-reversed within each 8-byte word against every real .sav. ⚠️ A save state
    written before this, of a game that uses EEPROM, comes back with its in-game save
    byte-swapped.
    The state format is unchanged and still loads, but that game's save
    inside it will not be read — re-import the .sav.
  • SRAM_F_V cartridges get their SRAM. Detection looked for the literal SRAM_V,
    which SRAM_F_V102 does not contain, so those games had no working save at all.
  • A 64 Kbit EEPROM is read at the right addresses. The width was latched at 6 bits by
    the sixth bit of any address and never revised, so a 64 Kbit cartridge read the wrong
    words for the whole run. It now comes from the length of the read the game makes, which
    is what actually carries it.

Deliberately left alone

  • A 64 Kbit EEPROM whose very first save access is a write still gets 6-bit
    addressing.
    A write carries no length — 64 data bits follow the address with nothing
    marking where it ended — so settling the width from one would need the DMA transfer's
    word count plumbed to the chip, which is how mGBA and VBA-M do it. That is main's
    behaviour, not something this branch introduced, and no ROM on hand exercises it. The
    changeset says so plainly rather than implying it is fixed.
  • An imported file settles nothing about the width. A 4 Kbit save padded out to 8 KB
    is a file several emulators write; trusting its length forced 14-bit addressing on a
    4 Kbit cartridge and desynced the line. The cartridge's own first read decides, and the
    file's length only answers for how big an export is until then. Both reviewers instead
    suggested sniffing the file's contents — rejected, since a file's bytes are no more
    evidence about the cartridge than its length is.
  • Nothing validates that a .sav is the right game's save. A file of a legal size for
    the declared type is accepted whatever is in it. Nothing in the format says what it is.
  • No new save-state version. An imported state is an ordinary state file, named after
    the file, with a name already taken getting (2).

🤖 Generated with Claude Code

macabeus and others added 9 commits September 12, 2026 22:28
The EEPROM array was byte-reversed within each 8-byte word against every
real `.sav`: the 64-bit word goes out most significant byte first and the
GBA is little-endian, so the byte a game sends first is the last of the
eight in memory. A retail Klonoa save written into the array straight was
read as garbage and the game reformatted over it.

Detection now reads the SDK string the build embeds rather than asking
whether one is roughly there: word-aligned, longest prefix first, three
version digits. `SRAM_F_V102` cartridges get their SRAM for the first
time, and the ROM's declaration is kept so a caller can name it.

`readBackup` and `writeBackup` are a `.sav` in and out, the EEPROM taking
the address width its size implies — auto-detection latches 6 bits at the
first transfer and never revises, so a 64 Kbit save that waits for it is
misaddressed for the rest of the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`importSaveState` builds a power-on machine of this ROM with the file
already in its cartridge and encodes it at frame 0 — the convention
VBA-M's `Import battery file` and mGBA's `Load alternate save game`
follow, and what makes the result independent of wherever the session
is stopped. It builds that on a machine of its own, so nothing about
the one being debugged moves.

`cartridge-save.ts` is the single home of which file belongs in which
chip and of every message that says why one belongs nowhere, so the
debug adapter and the in-process transport agree by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file crosses base64-encoded in an ordinary request; at 64 KB worst
case it is smaller than the symbol files `gba-kit/importLabels` already
carries. The state is written under a name no state has, so importing
the same file twice keeps both, and neither request touches execution,
so no client sees a stop it did not cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ⋯ button beside `Save state` opens a menu with the two actions. Where
the file dialog lives is the host's business: `pickFile` and `saveFile`
join `openText` as optional capabilities on `Transport`, VS Code serves
them with the editor's own dialogs the way `gba-kit.importLabels` does,
and the webapp with an `<input type="file">` and a download. An item
whose host can serve neither is not shown at all, so an embedder that
passes no options sees the bar exactly as it was.

Neither item is gated on `stopped`, unlike the button beside them: the
import builds its snapshot on a machine of its own and the export copies
the backing arrays, so neither borrows the machine's execution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether the 0x0E window has a chip behind it is the cartridge's to say,
like the ROM itself: a state carries the field so the format is unchanged
but no longer overrules the ROM with it, which is what would otherwise
take an `SRAM_F_V` cartridge's SRAM away again on every state loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found three ways an import could report success on a save the game
would never read.

**The address width belongs to the cartridge, not the file.** `EEPROM_V` does
not say whether a chip is 4 Kbit or 64 Kbit, and neither does a file: a 4 Kbit
save padded out to 8 KB — what several emulators write — put the chip in 14-bit
addressing, where the game's 6-bit requests desynced the serial line for the
rest of the run. The width now comes from the length of the transfer the game
makes, which is what carries it: a read's address phase ends when the game turns
around and reads, so `read` closes it and counts the bits that came. A file only
suggests a width until then. This also settles the 64 Kbit case auto-detection
never could, since it latched 6 bits at the sixth bit of any address and never
revised.

**A flash cartridge is refused in both directions.** `FLASH1M_V` was refused for
having two banks, but the reason was only half of it: gba-kit backs the 0x0E
window with plain memory and emulates no flash chip at all. Sonic Advance 1, 2
and 3 each write the identify sequence, read save bytes where a chip ID should
be, give up, and never read the save again — leaving two command bytes in the
save data that would go out in the user's next export. Refusing says so.

**A `.sav` picked and then dismissed no longer wedges the web page.** The
browser's `pickFile` listened for `change` alone, so a cancelled dialog never
settled its promise and the save-state bar stayed disabled for good.

Along with them: an import numbers a repeat of a name too long for its file, a
mis-picked file is turned away by its length before it is encoded, the trigger
of the ⋯ menu closes it on Escape, `gba-kit/exportSave` drops two fields no
client read, and `serveTransport` is told how to put a capability's answer on
the wire rather than sniffing it for a `bytes` field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ncoding it

Six findings from the second review round.

The address width no longer comes from an imported file's size at all. It
could only ever be a guess — a 4 Kbit save padded out to 8 KB is a file
several emulators write — and a guess the cartridge could not revise: a read
settles the width, but a write has 64 data bits behind its address and no
turnaround to end the address phase, so it takes the width as given. A game
whose first EEPROM access was a write therefore lost that write, and every
transfer after it, purely because a `.sav` had been imported. The chip now
keeps only the file's length, which answers for how big an export is until a
read settles the real width; `eepromAddrBits` on the bus becomes
`eepromSaveBytes`, and `saveFileSize` takes that instead of the width.

A read that turns around at a bit count no chip addresses with leaves the line
carrying something it lost the framing of, so the chip goes idle for the next
start bit rather than staying in an address phase that swallows every request
after it. Stimulus that desynced the line recovered 396 times in 400 before
this change was made and 162 after it; it recovers 396 again.

`pickFile` carries the caller's `maxBytes`, and every implementation refuses a
bigger file where its bytes already are. The extension host used to base64 a
picked file before anything looked at its length: 64 MB cost it 3.8 s and
1.2 GB of RSS, and 200 MB was a fatal heap OOM that would have taken every
extension down with it.

In the browser, a file the page cannot read now reports that instead of never
settling and leaving the save-state bar disabled for good, and the pre-`cancel`
focus fallback is armed only on a browser that needs it — on a modern one it
answered "dismissed" for a user who was still choosing and dropped their file.

`safeName` kept a trailing digit run so a `(2)` survives truncation, but with
no bound on how long that run could be: a name ending in 240 digits came back
longer than it went in, and the import failed with ENAMETOOLONG. Only a run
short enough to be a mark is kept now.

Two imports of one name sent without waiting both found the name free and the
second wrote over the first, in the adapter and in the in-process transport
alike. Imports queue behind one another in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `.sav` work moved the EEPROM's address width, the state files' paths and
the save-state actions, and left comments standing that described the shape
each of them had before. Every comment here now reads against the code beneath
it: the chip's width comes from the read the game makes, a snapshot without an
installed length restores as 0 the way `frameCount` does, `importSave` gives
out a state path like the two beside it, and only the actions that change the
state list refresh it. The byte order the EEPROM keeps its words in is said
where both loops that depend on it can be read, and the `id` that makes a
transport message one the host answers is said where it is asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…carries

The changeset left out two things it should state. `@gba-kit/gba-node` has a
hand-written snapshot serializer that now enumerates `installedBytes`, so it
belongs in the bump list beside the emulator whose snapshot it writes.

The export side had no paragraph of its own: a `.sav` comes out at the size the
cartridge really has rather than the size of the array behind it, and an EEPROM
that has told nobody its width is refused rather than guessed at 4 Kbit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant