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:
StateWriterwrites 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.StateReaderreads 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.