JNEXT
Skip to content

2.5 Save state, rewind and determinism

What rewind is

JNEXT can run the emulated machine backwards. In the debugger you can press Shift+F7 to undo the instruction that just executed, Shift+F6 to jump to the start of the previous frame, or drag a slider to any frame still held in the buffer — and the entire machine goes back to exactly how it was at that point. Not just the registers: memory, the MMU mapping, the video registers, the peripherals, everything. You land there paused, with the emulator window redrawn to match, and F5 carries on from there. For debugging a crash this inverts the usual workflow, because you can step away from the wreckage towards its cause instead of restarting and trying to catch the moment on the way past.

It is opt-in, via --rewind-buffer-size N on the command line or Debug ▸ Rewind ▸ Enable Rewind in the debugger, because it works by snapshotting the whole machine at every frame boundary and those snapshots are large. The user-facing description lives in the user guide, under Debugger ▸ Backward execution (rewind).

That is the feature the rest of this page implements. Everything hangs off one requirement: the ability to put the entire machine into a byte buffer and get it back out again. Save states, snapshots and rewind all use the same mechanism, and that mechanism is a convention rather than an inheritance hierarchy.

The serialisation convention

There is no Saveable base class. src/core/saveable.h, despite the name, defines only two concrete helpers:

  • StateWriter writes into a pre-allocated buffer. Constructed with a null pointer it runs in measure mode instead, writing nothing and simply counting the bytes it would have written.
  • StateReader reads back from a const buffer.

Both are bounds-disciplined by construction. A write past capacity is suppressed, a read past the end is zero-filled, and either latches a sticky flag — overflow() and out_of_bounds() respectively. The position counter keeps advancing in both cases, so a diagnostic still reports the offset the code intended, which is the number you need to find the culprit. No overrun can corrupt memory.

Each subsystem then simply has the two methods:

void save_state(StateWriter& w) const;
void load_state(StateReader& r);        // or bool, where it can fail

Emulator::save_state() (src/core/emulator.cpp) calls them in a fixed order — core, video, peripherals, audio, then its own private fields — and writes a sentinel after every block: a u32 holding kStateSentinelMagic ^ ordinal, with a shared counter keeping the two sides in lockstep. load_state() verifies the same sequence, and on the first mismatch it logs an error naming the subsystem that went wrong and returns false, rather than feeding desynchronised bytes into everything downstream and producing a machine that is subtly wrong in an unrelated place.

A failed load leaves the machine torn, and JNEXT says so rather than pretending otherwise. last_state_error() holds the name of the offending subsystem until a reset re-establishes the machine cleanly, and state_error_generation() is a monotonic counter so the GUI can distinguish a fresh incident from one the user has already acknowledged. Acknowledging does not clear the breadcrumb, for the obvious reason that acknowledging does not heal the desynchronisation.

A comment above save_state() lists what is deliberately not serialised, and the omissions all have reasons: the ROM buffer and boot ROM are loaded from disk and never change; the contention model is rebuilt from config; the port dispatch table is a set of lambdas that init() rewires anyway; and the mixer, the debugger's transient state, the trace and call stack, tape position, the recorders and the framebuffer are either host-side or reconstructible.

The SD card used to be on that list and is not any more. Its SPI protocol state machine travels in an appended sdcard block, because a rewind taken while a loader was streaming a CMD18 multi-block read restored a card that was no longer streaming, and the replayed frames then received a fresh idle card's R1/token framing where sector bytes belonged. On a Next that is most loaders. Only the protocol state travels; the mounted image is an external resource shared by every slot in the ring, exactly as the esxDOS host handles are. That is why a live SD-card change empties the ring (RewindBuffer::clear()): every slot would restore one card's state machine against another card's contents.

The subsystems do not each hand-write that serialisation any more either. Every one of them — and the emulator's own scalars — declares its fields once, by name, in a describe_state(StateDesc&) method, and save_state/load_state are both a walk of that one declaration (src/save/state_desc.h). The binary realisation emits exactly the bytes the hand-written pair emitted, which is what let the migration be checked against a byte image of the pre-migration stream; the point of it is that the rewind ring and the .jns file cannot disagree about which fields exist, because there is only one list.

The .jns file

The rewind ring's stream is positional and never leaves the process. A .jns is the same machine written to disk, and it is a different shape for reasons that are worth being explicit about:

rewind ring .jns
framing one flat byte stream a ZIP, manifest.json first
a subsystem bytes at an offset state/<name>.json
a field a position a name
desync a sentinel per block member and key names, structurally
bulk memory inline mem/*.bin members, declared with a length and a CRC
lifetime this process somebody else's disk, next year

Both are walks of the same describe_state declarations — BinWriteDesc for one, JsonWriteDesc for the other. That is the whole purpose of the descriptor layer: a field cannot exist in one and not the other, because there is only one place it is written down.

What assembles a whole file is Emulator::save_jns / load_jns (src/core/emulator_jns.cpp). The list of subsystems it walks lives in Emulator::visit_jns_subsystems, a member template that both directions run through a visitor — one list, so a subsystem added to the save side and forgotten on the load side is not expressible.

Four things have no declaration and are hand-written there, each for a stated reason: the folded monotonic T-state instant and the CPU's /INT window are written relative to a counter the stream does not carry and are re-seated with side effects on read; the esxDOS handle table is a variable-length list; and the joystick cable's presence is a flag rather than a member. The first two are staged into jns_*_ members so a sixth describe_* method can still declare them by name.

The manifest carries what the bytes cannot: the machine type and RAM size the file needs to be reconstructed into, the jnext version that wrote it, and the identity of every external resource the machine was using — the SD card, the ROMs, the tape. None of those are copied: an SD image is a gigabyte of somebody else's firmware. They are recorded so a mismatch is detected rather than silently wrong, which is the same shape the esxDOS handles already used. The tape is recorded by the loaders' absolute path (TapLoader::path() and its two siblings, built by jnext::absolute_host_path()), beside the display name filename(), with the digest of the bytes the loader read (sha256()). A host path that is not UTF-8 is written with U+FFFD instead of aborting the save. A tape extracted from a .zip also records media.tape.archive and media.tape.member: ProgramArchive::extract() registers every file it writes (program_archive::extracted_from()), so all load routes are covered, and the scratch copy dies with the process.

The deck's position is the hand-written member state/tape.json (outside the generated schema, like esxdos_hostfs), written and listed in subsystems only when a tape is attached. TapLoader, TzxLoader and WavLoader each expose a DeckState (deck_state() / restore_deck()); times are signed distances from the save instant and file pointers are byte offsets. The member also carries the poll detector, the phantom typist and the recorded auto play. On load, Emulator::reattach_jns_tape_() runs last (the state round trip resets the detector and, like every load_state(), a rewind's included, closes the fast-load pacing window): it re-opens the tape (from the archive when there is one), checks the digest, and has the loader validate every value against the file before installing it. Any failure is ONE warning and the deck stays as it was. A file without the member (older jnext) is not reattached either, since attaching at the start would hand block 0 to a program waiting for a later one.

A snapshot changes no setting (owner decision). The session's mode reaches the core as JnsLoadOptions::tape_realtime (the one fast/real-time setting: the Tape > Fast Load toggle, which --tape-realtime overrides for the run in main.cpp; QtApp::tape_realtime() reads the toggle, SDL and headless take the flag) and tape_auto_play_; a deck recorded in the other mode is converted at a block boundary: handover_to_fast_at_gap() (finish the block in real time, then fast at the next gap), start_realtime_from_fast() / start_playback_from_fast_position() (real time from the fast cursor, held under auto play), and the auto play rules of set_tape_auto_play. Tests: rewind_test S6-EMU-TAPE-*, the loaders' TAPD/TZXD/WAVD rows, snapshot_test JNSN-31, S6-MEDIA-02 and S6-TAPE-03, and the Linux-only jns_host_path_linux_test. All the machines in one process share one live T-state counter, so a test runs A to its end before it builds B.

The test that matters is in rewind_test: save a running machine to a .jns, load it into a second machine, and require the two machines' binary state streams to be byte-identical. A subsystem missing from the one list is never written, so the restored machine keeps its reset defaults and the comparison fails. JNS-RT-05 proves the comparison still discriminates by checking that a machine which was never loaded does differ.

A save may only be taken at a frame boundary, and the debugger breaks mid-frame — which is exactly when a developer reaches for File ▸ Save Snapshot. Emulator::advance_to_frame_boundary() completes the in-flight frame first, through the ordinary run_frame() path so that the frame is resumed and not restarted: re-running begin_new_frame() mid-frame clears the per-scanline change logs, and a save that quietly destroyed a frame's raster history would be worse than one that refused. The save always works; the cost is that the restored machine is up to one frame past where the user paused.

The rewind ring

RewindBuffer (src/debug/rewind_buffer.h) is a ring of fixed-width slots backed by a single anonymous mapping, obtained from anon_mem::alloc in src/core/anon_mem.h. The mapping matters: 500 frames of a snapshot around 2.3 MB is over a gigabyte of address space, and an anonymous mapping lets those pages fault in lazily as they are first used instead of being eagerly zeroed up front. The slot size is decided once, at construction, by running save_state in measure mode:

StateWriter measure;  save_state(measure);
size_t snap_bytes = measure.position();

Snapshots are taken from Emulator::begin_new_frame(), which is to say at a frame boundary — and that is precisely why the scheduler queue never has to be serialised, because it is empty at that instant.

The ring is host state, so only a hard reset rebuilds it. A soft reset (NR 0x02 bit 0, F4) re-runs init() too, but leaves the ring, its size and replay_mode_ alone: the reset is one more event in the history, and a rewind that re-executes it stays a replay.

This is also why every serialised field must be constant-width. take_snapshot() refuses to publish a slot whose save_state did not write exactly snapshot_bytes. That refusal is the correct behaviour, but it was silent, and the consequence was ugly: three fields were being written count-prefixed and variable-length, so a single OUT (0xFF),A or one byte arriving on the UART widened the stream, and from that point on every snapshot was quietly discarded — rewind simply stopped recording, with no error. The header of rewind_buffer.h now carries the audited list of runtime-variable state along with the rule that anything added to it must be fixed-width.

Three entry points use the ring, and all of them funnel through load_state():

Call What it does
rewind_to_cycle(target) Restore the nearest snapshot at or before target, then fast-forward to the exact cycle in replay mode.
step_back(n) Use the TraceLog to find the start cycle of the instruction n back, then rewind_to_cycle it. Fails loudly if the trace is off — which is why enabling rewind also enables the trace.
rewind_to_frame(num) Restore a specific frame's snapshot, and render it straight away.

Frame numbers. A slot is tagged with the number of the frame it begins, and it is taken just after the frame counter has counted that frame, so a restored machine already holds frame_num() = K+1 for the frame K it is about to run again. Emulator::at_restored_frame_start() marks that state (host-side, never serialised): begin_new_frame() then counts the frame once, not twice, and a debugger can tell the boundary before a restored frame from an ordinary boundary after a frame. When the machine runs forward from a rewind, the first new slot drops every slot from its frame on — the history the rewind left — so the ring always holds one timeline, one slot per tag.

replay_mode_ suppresses audio mixing and rendering throughout the fast-forward, so rewinding several seconds neither screeches through the speakers nor flickers through hundreds of frames; the picture is rendered once, at the end. And if the restore fails verification, the rewind is aborted and the machine is left paused rather than reported as a success.

The video subsystems' per-scanline render history — the change logs with their frame-start baselines, and the per-line snapshot arrays (see 2.4 The video pipeline) — is not in the stream, bar the NR 0x4A fallback, the border and the port 0xFF log. begin_new_frame() would rebuild it, but rewind_to_frame() renders before any begin_new_frame() runs. So every load_state() rebuilds it from the registers it has just loaded, and Emulator::load_state() re-syncs the two pieces that need another subsystem: the Ula's copies of the palette-bank selectors, from PaletteManager, and the attribute mux, from the restored VRAM. Without them that render's rewind_to_baseline() would write the pre-rewind frame's palette, Layer 2 registers, sprite attributes and patterns, NR 0x6B and ULA scroll back into the live registers, and the rewound machine would carry them on.

One category of state needs special handling: host-side state that shadows emulated state. The joystick and mouse dispatchers live in the platform layer and are not Emulator members, so a restore cannot reach them directly. Instead they are re-seeded through the on_input_state_restored callback, fired at the end of every successful load. Without it, the next live host input event would stomp on the restored machine.

Determinism

An automated run has to produce the same bytes on a fast desktop and on a loaded CI runner. Four things make that true, and it is worth noting that all four live in the emulator rather than in the test harness — a harness cannot make a non-deterministic emulator reproducible.

Count frames, not seconds. --delayed-screenshot-frames, --delayed-automatic-exit-frames, --delayed-keypress-frames and --delayed-snapshot-frames are all counted in emulated frames; --delayed-screenshot-frames and --delayed-automatic-exit-frames override their seconds counterparts when both are given, and the keypress and NMI forms queue instead. The seconds forms are not wall-clock either: they are cli::Delay (src/platform/cli_delay.h), which counts in 1/300 s units and is charged once per frame at the refresh rate that frame ran at (1/50 s or 1/60 s), so a guest that switches to 60 Hz is timed in its own seconds. The rate is always 50 Hz at startup, which is why the conversion cannot be done once when the option is read.

Pin the clock. --rtc sets EmulatorConfig::rtc_fixed_tm, which reaches I2cRtc::set_fixed_time(). The emulated DS1307 then answers from a frozen time instead of the host clock, so anything that draws a date — the NextZXOS menu being the obvious case — comes out the same on every run.

Headless never reads the saved configuration. AppConfig is loaded only on the non-headless Qt path, so a developer's saved preferences cannot change what an automated run does. Everywhere else, CLI values win over saved ones.

A missing capture is an error, not a silent pass. If a requested --delayed-screenshot or --delayed-snapshot was never actually written — because the exit bound fired first, or the debugger stayed paused so no frame was ever rendered for it — the process logs an error and exits non-zero. A test that produces no output fails instead of passing.

There is one deliberate asymmetry to be aware of. The frontend render hint that lets the Qt GUI skip compositing frames nobody will see is never set in headless mode, and is overridden whenever recording or the debugger is active, so headless output stays bit-identical to a fully-rendered run. See 4.3 The regression suite for how all of this is used in practice.