How it works · write-up
Save and load for players
Status: implemented and merged into xenon2 (2026-10-05); the results are near the end. Written
as the plan on branch save-load, from xenon2 at 5b74d502, and revised after review
(2026-10-03). Since then SAVE GAME and LOAD GAME have moved to the top of the options menu.
Requirements
- Reached from the existing options menu (ESC on desktop, the corner icon on touch), in the desktop and the browser build.
- At most 5 save slots. No deleting; a slot is reused by saving over it.
- A screenshot of the sprite view, which is what the player sees, is stored with the save and shown while choosing a slot.
- Each save is described by its level and progress, e.g.
LEVEL 3 - 50%. Progress comes from the camera's y position. The file names carry the description without the%. - The options menu's player settings are saved with the slot and restored on load: HIRES SPRITES, HIGH FPS and the AUTOPILOT row (OFF, AUTOFIRE, AUTOPILOT).
- The autopilot's own state is not saved: a load starts a fresh session, which rebuilds it.
- After a load, play continues at once.
- Saves live in their own folder: on desktop a
savesfolder next to the program, separate from the recordings' run folders. In the browser they survive closing and restarting the browser. - A load can switch between low and high resolution sprites and between levels, so the right sprite atlas has to be in place (and in the browser downloaded) before play continues.
What exists today
Options menu (src/xenonOptionsMenu.c). It is drawn entirely as sprite-stream quads, so the
same C code runs on desktop (SDL GPU) and in the browser (WebGL). Opening it pauses emulation
(Main_PauseEmulation). Screens are lists of rows: the main screen has HIRES SPRITES, HIGH FPS,
AUTOPILOT, RECORD and DEVELOPER OPTIONS; the developer screen and its BACK row show how a sub-screen
works. Keys (arrows, W/S, Enter, Space, fire key) and taps share one row model.
Snapshots (src/memorySnapShot.c). MemorySnapShot_Capture_Immediate writes a gzipped
snapshot (about 620 KB in Level 3) and then the .x2meta sidecar. MemorySnapShot_Restore
defers the load to the CPU loop (m68k_go). The automation protocol already saves and loads
this way, including a load requested while emulation is paused (XENON_MSG_LOAD_STATE,
src/xenonControl.c: unpause, restore, pause again). After every restore,
XenonControl_OnSnapshotRestored starts a new run id, replaces a running autopilot session with
a fresh one, clears input state, resets the draw stream and interpolation, and sets the atlas level
from the restored RAM.
Browser build. No save or load at run time: a snapshot is only loaded at start-up
(--memstate, plus the ?snapshot= dev switch in src/emscripten_shell.html). Files live in
emscripten's in-memory filesystem. Recordings are kept in IndexedDB (web/src/recordingStore.ts).
The CPU loop's quit-and-restore path is the same code as on desktop (src/cpu/newcpu.c,
m68k_go), but a restore after start-up has never been exercised in the browser.
Frame readback. Both renderers can already read a rendered sprite-stream frame back, but only
while a consumer asks for it:
- Desktop (src/sdlGpuRenderView.c): when the frame-capture diagnostic or the sprite AVI is on,
the frame is drawn into captureIntermediateTexture, blitted to the window and downloaded with
SDL_DownloadFromGPUTexture. The result arrives a frame or two later.
- Browser: gl.readPixels in the sprite-stream view, used by web/src/frameCapture.ts.
Sprite atlases. Textured quads are looked up by sprite id in two atlas banks, shared and
per-level, each in a low (1x) and an HD (4x) version (src/spriteAtlas.c).
- Desktop reads the files from disk.
- The browser fetches them on demand (setAtlasConfiguration in
web/src/views/spriteStreamLayer.ts). An HD level bank is 30–43 MB, and the previous set stays
applied until the new one has arrived.
Option state. HIRES SPRITES is SpriteAtlas_GetRequestedSlot(), HIGH FPS is
SpriteStreamInterpolation_IsEnabled(), AUTOPILOT is the menu's own s_autopilotMode plus
XenonControl_IsAutopilotRunning(). None of them is persisted.
Level progress. In the recordings the camera scroll (scroll_y) runs from 4608 at the start
of each level down to 0 at its end. That was measured for Levels 1–4 in 14 recordings; a Level 5
level start also jumps from 0 to 4608.
Autopilot after a load. The restart audit (2026-10-02) found every session, driver and mission field initialised, and the level state rebuilt from the observation and terrain RAM. After 5b74d502 the Level 3 carrier fight is also rebuilt from the carrier. So only the autopilot mode needs saving.
The .x2meta sidecar
Written by ObjectIdentity_SaveSnapshotSidecar (src/objectIdentity.c), 1 to 2 KB. It holds:
- the object identity registry: which ST object slot currently stands for which logical object identity;
- the identity namespace and the next identity number;
- a fingerprint of the first 1 MiB of ST RAM, checked on load;
- the capture frame counter (the game frame numbering).
If it is missing or does not match the RAM, the load still works: Hatari starts a fresh identity namespace and keeps counting frames from where the process was.
Does the autopilot need it? Yes. Two separate effects:
- The autopilot's memory does not survive a load either way. Every load, in the same program run
too, replaces the autopilot session with a new, empty one (
XenonAutopilot_Reset). Object tracks, velocities, mission state and the kept plan restart from the next frames, so decisions after a load can differ from what an uninterrupted run would have done. Restoring identities does not change that; the restart audit covered how the state is rebuilt. - Identity values matter. Besides following an object from frame to frame, the autopilot orders objects by identity number:
- the tie-break between equally good wall-gun firing positions
(
xenon_autopilot_tactics.c:131); - the order of attack targets given to the planner
(
xenon_autopilot_source_selection.c:166); - which object leads a formation group (
xenon_autopilot_world_models.c:122).
Without the sidecar, objects alive at the load get different identity numbers (their raw memory addresses), so these orderings, and some decisions with them, could differ.
With the sidecar restored, a loaded save always has the identities it had when it was saved, also in a later session, so loading the same save twice gives the same decisions.
Who else uses it:
- Recordings and the replay page: identities and game frame numbers continue from the save instead of restarting.
- The automation harness: runs resumed from checkpoints keep their frame numbers (a run from
f20651-periodic.savstarts at frame 20652).
Decision. Save and restore it with every player save, unchanged. The snapshot code already
writes it next to the .sav and reads it on load. Do not extend it: it is engine bookkeeping tied
to one exact RAM image, discarded whenever the fingerprint does not match. The player data
(settings, description) has a different job and goes in a separate slot file.
Design
Save folder and files
- Desktop: a
savesfolder next to the program (SDL_GetBasePath(), the folder ofhatari.exe). It is created on the first save. - Browser:
/saves, an emscripten IDBFS mount backed by its own IndexedDB database, separate from the recordings' database. - It is loaded once at start-up and written back (
FS.syncfs) after each save, so saves survive closing the tab and restarting the browser. - On the first save the page asks for persistent storage (
navigator.storage.persist(), as the replay library's "Keep permanently" does), so the browser does not evict the saves when space runs low. - Link with
-lidbfs.js.
The C code is the same on both builds. A slot is four files named after the slot and its description, for example slot 2 saved in Level 3 at 50% progress:
| File | Written by | Content |
|---|---|---|
Slot 2 - Level 3 - 50.sav |
MemorySnapShot_Capture_Immediate |
The Hatari snapshot, unchanged. |
Slot 2 - Level 3 - 50.sav.x2meta |
the snapshot code, as today | Identity sidecar, unchanged. |
Slot 2 - Level 3 - 50.png |
new | The screenshot of the sprite view. |
Slot 2 - Level 3 - 50.x2slot |
new | Player data, written last. |
- Only spaces, letters, digits and
-are used, which are valid on every file system. - The
.x2slotfile is what makes a slot exist: a slot's files are found by theSlot N -prefix, and only the set with a valid.x2slotcounts. - Saving writes the whole new set under the name
Slot N.saving, renames it into place with the.x2slotlast, and only then deletes the slot's previous set. - A save cut short before
Slot N.saving.x2slotexists leaves the previous slot loadable; the leftover temporary files are deleted at the next start. - After that point, the next start finishes the renames and deletes the previous set. Files that had already been renamed stay.
The .x2slot contents, 52 bytes, little-endian:
- magic
X2SLOT\0\0, format version; - the build's snapshot format id, so the menu can mark a slot from an incompatible build as unusable instead of failing on load;
- save time (Unix seconds), game frame;
- level, progress percentage, score, lives, shield;
- settings: HIRES SPRITES, HIGH FPS, autopilot mode; whether a screenshot was saved.
Description and progress
progress = 100 * (4608 - scroll_y) / 4608, rounded down and clamped to 0–100, wherescroll_yis the camera's y position (the top of the screen, from game RAM). The camera is steadier than the ship's own y, which moves around within the screen.- Description:
LEVEL 3 - 50%in the menu,Level 3 - 50in the file name. - The menu's HUD font has only
A-Z 0-9 .and a:that draws as two dots side by side.-,%and:are drawn as 8x8 pixel-font glyphs made of flat blocks in the text colour. - SAVE GAME is available only while a level is in progress: in play, in the shop, or in the menu over either. On the title and high-score screens the row is disabled and its hint says why.
Screenshot: the sprite view, from the readback
The screenshot is taken when the menu opens, because that frame shows the game exactly as the player left it. It is kept until the menu closes, and used if the player saves.
- When the menu opens, the frames are drawn without the menu (and without the touch controls)
until the screenshot has been taken. Emulation is paused, so these frames are the last game
frame drawn again.
- Desktop: the frame is drawn with a one-shot readback request, the same capture the sprite AVI
uses but asked for once instead of every frame.
- Browser: after the frame is pushed, C asks the sprite-stream view for the screenshot. The view
draws the frame and reads it back with
gl.readPixelsat once, inside that call, rather than at the next animation frame: a hidden or background tab gets no animation frames. - The menu appears once the pixels have arrived; if none arrive within 30 frames, it appears anyway and the save has no screenshot.
- The readback is scaled down to 320x200 (a box filter), one pixel per logical screen pixel, and kept in memory.
- On save it is written as a PNG beside the snapshot, so it can also be opened in any image
viewer.
src/xenonPng.cwrites and reads the PNG with zlib on both builds, because the browser build has no libpng.
Showing the screenshot in the menu
The PNG is the stored form; the menu still has to draw it. The menu is drawn by the sprite-stream renderer, which draws only atlas sprites (looked up by sprite id) and flat palette colours. A stored picture has no atlas entry, and the atlas holds only one level's sprites at a time, so the saved frame cannot be redrawn from its sprites either. So the renderer needs one small addition: a third atlas bank, IMAGE, next to SHARED and LEVEL, holding the decoded PNG of the selected slot.
- C: the save module decodes the selected slot's PNG and hands the pixels to
RenderImage(src/renderImage.c), which counts a generation for each change. The reserved sprite idRENDER_IMAGE_SPRITE_ID(0xFF000001) resolves to the IMAGE bank, so the menu draws the preview as an ordinary textured quad. The picture changes only when the selection moves to another slot or the slot is saved again. - Desktop:
SdlGpuRenderViewuploads the picture into one more texture when the generation changes and binds it as a third sampler. The fragment shader samples it for bank 2; the 4x HD scale does not apply to it. - Browser:
webRenderBackend.cpasses the picture to the sprite-stream layer when the generation changes; the layer uploads it as a texture, and its shader has the same bank 2 branch. The quad format and the quad decoder (web/src/spriteQuad.ts) stay as they are.
Menu
- Main screen: two new rows, SAVE GAME and LOAD GAME, at the top. Each leads to a slot screen.
- Slot screen:
- five rows,
1 LEVEL 3 - 50%or1 EMPTY, and BACK; - the right-hand preview area, where the HIRES and AUTOPILOT previews draw now, shows the
selected slot's screenshot, with its date and time (
03.10.2026 12:33), lives and settings underneath. - Save:
- an empty slot is saved at once;
- an occupied slot asks first: the hint line changes to
PRESS FIRE AGAIN TO OVERWRITE; - afterwards the row shows the new description and the hint
SAVED, and the menu stays open. - Load: an occupied slot loads and closes the menu, and play continues. Empty slots and slots from an incompatible build do nothing, and their hint says so.
- Touch: the existing row hit-testing covers it; the rows are the same height as today.
Save flow (new module src/xenonSaveSlots.c)
- The menu is open, emulation is paused, and the screenshot was taken when the menu opened.
- Read level, scroll, score, lives and shield from RAM and build the description.
MemorySnapShot_Capture_Immediateinto the new.sav; the sidecar follows automatically.- Write the screenshot PNG.
- Write the
.x2slot, rename the set into place, then delete the slot's old set. - Browser:
FS.syncfs. A failure, for example quota, is logged to the console.
Load flow
- Read and check the
.x2slot: magic, version, snapshot format id. - Show
LOADINGand restore the snapshot, the same way the protocol's paused load does. The menu closes when the load has finished (step 3), and takes no input until then. - When the restore has completed (
XenonControl_OnSnapshotRestored): - Apply HIRES SPRITES (SpriteAtlas_SetActiveSlot) and HIGH FPS. The atlas level is already set from the restored RAM. - Apply the autopilot mode: start a fresh native session for AUTOPILOT, stop a running one for OFF or AUTOFIRE, and set the menu's mode. The autopilot handles its own warm-up frame and terrain RAM sync. - Desktop: continue at once; the atlas files are read from disk. - Browser: stay paused, withLOADINGshown, until the sprite-stream layer reports that the atlas set for the restored level and resolution has been applied, then continue. This needs a small bridge call fromsetAtlasConfigurationback to C. Without it the game would run on the previous level's sprites while a 30–43 MB HD bank downloads. - A recording in progress keeps recording. The restore starts a new run id, and writes an autopilot reset event when the autopilot runs; the replay core starts a new autopilot session when the run id changes.
Browser spike (2026-10-03): run-time save and load work
Step 1 is done. src/xenonSaveSlots.c had three temporary exports (since removed), called from the console of
the browser build (out\build\wasm-release\src, served by the wasm-save-spike preview):
XenonSaveSlots_SpikeSave(path), XenonSaveSlots_SpikeLoad(path) and
XenonSaveSlots_SpikeStatus(). Save calls MemorySnapShot_Capture_Immediate; load calls
MemorySnapShot_Restore and unpauses a paused emulator, like the protocol's paused load.
| Case | Result |
|---|---|
| Save in Level 1 with the autopilot on, frame 556 | /tmp/spike1.sav 608,802 bytes plus a 636-byte .x2meta |
| Load 15 s later, while playing | Back at frame 556, scroll 4331, score 2210 within 50 ms; play and the autopilot continue; picture correct |
| Load from the open options menu (paused) | Same: back at frame 557, scroll 4330, then playing |
Load a Level 3 checkpoint (f20651-periodic.sav and its sidecar) while in Level 1 |
Level 3 at frame 20654, the carrier fight, autopilot running; Level 3 sprites correct (low resolution) |
| HIRES SPRITES on, in Level 3, load the Level 1 save | The first frame shows only the HUD over a black play area; Level 1 is drawn correctly in HD about a second later |
Findings:
- The restore path in the browser's CPU loop works, from a running and from a paused emulator. The sidecar restores the frame counter (frame 556, frame 20654).
- The two loads of the same save produced the same scroll and score at the same frames (frame 572: scroll 4315, score 2250 both times; frame 587: 4300, 2250), with the autopilot playing. That is a first sign of the reproducibility the sidecar is kept for; the full check is in the verification list.
- While an HD level atlas downloads, the browser draws the HUD over a black play area and the game keeps running. The HD level banks are 7.7–10.9 MB downloads (the shared bank 8.0 MB), so on a slow connection a player would fly blind for seconds. The planned wait after a load is needed.
- The options menu's corner icon is hidden during play until the touch controls wake up, so the spike opened the menu with ESC.
Risks, checked first
- ~~Loading at run time in the browser has never been done.~~ Done, see above.
- Desktop shader rebuild: make sure
compileShaders.pyand its compiler still run before relying on a shader change. - One-shot readback timing: the desktop download completes a frame or more later; the menu must not draw over the frame before it has been captured.
- Old saves after an update: a snapshot from a build with a different snapshot layout may not load. The slot file's format id lets the menu show such slots as unusable.
Implementation order
- ~~Browser spike: run-time save and load with the existing snapshot code.~~ Done.
- ~~
xenonSaveSlots.c: save folder, file names, slot file, save and load, settings, description.~~ Done. - ~~Menu: SAVE GAME, LOAD GAME and the slot screen.~~ Done.
- ~~Screenshot: the one-shot readback on desktop, and the IMAGE bank with its shader on desktop.~~ Done.
- ~~Browser: IDBFS mount and sync, persistent storage, the readback hand-over, the IMAGE bank in WebGL, and the wait for the atlas set after a load.~~ Done.
- ~~Docs: the options menu notes, and this plan's results.~~ Done, see below.
Verification for each step:
- Desktop runs: save and load in every level, in the shop, during a boss fight and with the autopilot on, each followed by a few minutes of play.
- Browser runs, on the PC and on the phone (touch), with the same cases. Then close the browser and load the saves after restarting it.
- Loads that switch between low and HD sprites and between levels, in the browser with an empty cache, so the HD bank has to be downloaded first.
- Loading a slot with each combination of the three settings restores them, and the menu shows the right values.
- The same save, made with the autopilot on, loaded twice (once after restarting the program),
with the autopilot playing: the joystick inputs of the two runs match frame for frame. Once
more with the
.x2metaremoved, to see how far the decisions drift without it. - A save interrupted halfway (process killed) leaves the previous slot loadable.
- A recording that spans a load opens on the replay page and replays correctly on both sides of the load.
Results (2026-10-03)
Browser (wasm-release, Chromium in the app's browser pane):
| Case | Result |
|---|---|
| Save, then reload the page | The slot is still there (IndexedDB); its .sav, .x2meta, .png and .x2slot are in /saves |
| Slot screen | 1 LEVEL 1 - 5%, the preview picture shows the game without the menu, then date, score, lives and settings |
| Overwrite slot 2 | PRESS FIRE AGAIN TO OVERWRITE, then SAVED; Slot 2 - Level 1 - 5.* replaced by Slot 2 - Level 1 - 8.* |
| Load with HIRES ON, HIGH FPS ON, autopilot OFF into a slot saved with all three off / AUTOPILOT | The menu shows the slot's values afterwards and the autopilot flies |
| Save in Level 3 with HD sprites, load it on a fresh page (Level 1, low resolution) | Back in Level 3 in HD with the autopilot on |
| Same, with the atlas downloads held back for 6 s | LOADING stays up until spriteatlas_shared_hd.png and spriteatlas_level_03_hd.png arrive, then play continues |
| Mouse taps (the touch path): menu icon, LOAD GAME, a slot, the slot again | Each tap selects, the second activates; the slot loads |
Desktop (mingw-debug):
| Case | Result |
|---|---|
| Save | saves\Slot 1 - Level 1 - 11.* next to hatari.exe; the PNG is the sprite view without the menu |
| Slot screen | The preview picture is drawn through the third sampler |
| Turn HIRES ON, play on, load the slot | Back at score 3270 in low resolution; the menu shows HIRES SPRITES OFF |
| Interrupted saves, made by hand, then a restart | Commit cut short after the .sav was moved: finished, the slot loads. Temporary files without a slot file: deleted. Two complete sets for one slot: the older is deleted |
Autopilot across sessions: the same slot .sav loaded over the control port and flown by the
resident autopilot for 299 game frames, once in a session that had been playing and once in a
freshly started one:
- joystick inputs, score, scroll and every object position matched on all 299 frames;
- the restored identities matched. Identities of objects spawned after the load are offset by a
constant (the session's identity counter never goes back,
objectIdentity.c), so their order is the same; - without the
.x2meta, the restored objects fall back to their slot addresses and new objects get small ids, which reverses their order. In these 299 frames the inputs still matched, so this run reached no decision that depends on the order.
A recording that spans a load (Level 1, then a Level 3 checkpoint loaded while the autopilot
flies) replays in the C replay core (replay_parity.py) with all 298 controls and 300 status
records matching.
Found and fixed during the checks: the first version deleted the slot's old files before moving the new set into place, so finishing a cut-short save at start-up deleted the snapshot that had already been moved.
Not checked yet: the phone, and a few minutes of play after loads in every level, in the shop and during boss fights.
Decisions from the review (2026-10-03)
- "High speed" meant HIGH FPS; no fast-forward setting is added.
- The screenshot is the sprite view, stored as a PNG file next to the save.
- The identity sidecar is saved and restored with every save, so the autopilot sees the same object identities after a load.
- After a load, play continues at once (in the browser once the sprites are loaded).
- Slots cannot be deleted.
- Desktop saves go in a folder next to the program.