Skip to content

Run the machine backwards, from a held Backspace or a REPL rewind - #39

Merged
dimiro1 merged 1 commit into
masterfrom
dimiro1/rewind-with-save-states
Aug 20, 2026
Merged

Run the machine backwards, from a held Backspace or a REPL rewind#39
dimiro1 merged 1 commit into
masterfrom
dimiro1/rewind-with-save-states

Conversation

@dimiro1

@dimiro1 dimiro1 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Rewind is a ring of whole save states — SaveState already round-trips from anywhere and already carries the framebuffer, so going back a frame is popping an entry and reading it, with nothing re-emulated.

Holding Backspace runs the game backwards at 2x with its own sound played in reverse and a ◀◀ marker painted over (never into) the picture, and rewind on / rewind N / rewind off do the same from the REPL, which is where it is checked: rewind on, run 90, rewind 30 lands on frame 60 with the same frame hash a plain run 60 gives, on nestest and on Super Mario Bros. alike.

The window keeps a state every other frame, which halves capture to 1.1–1.5ms a frame, doubles the history for the memory and gives back two frames per display tick; thirty seconds measures 4–22MB depending on the cartridge, and rewind.seconds=0 in ~/.mynes/config.properties builds no ring at all.

run.state.framesRewound joins startedFromPowerOn as something to check before diffing two reports (additive, so the report version stays 1); there are no new dependencies, and mvn -B test and scripts/smoke-distribution.sh are green.

🤖 Generated with Claude Code

Rewind is a ring of save states and almost nothing else. `SaveState.write` and
`read` are already public in-memory forms, the state already carries the
framebuffer in its `VBUF` chunk, and the machine is already deterministic -- so
going back a frame is popping an entry and reading it, and the picture the
display wants arrives inside the state. Nothing is re-emulated anywhere in this.

Whole states rather than diffs against the previous frame, which sounds
expensive and is not. The body is gzipped and most of a state is the framebuffer
and the cartridge's RAM, both largely one repeated value: measured mid-level it
is 2.5KB on Super Mario Bros., 7KB on Super Mario Bros. 2 and 13KB at Contra's
worst moment, so thirty seconds is 4 to 22MB rather than the hundreds the raw
figures suggest. A diff would still be smaller and is still not worth writing,
because this format is the one `SaveStateDivergenceTests` already proves round
trips from anywhere, mid-instruction and mid-scanline included, and a second
cheaper less tested way of putting a machine back is a second way for it to come
back subtly wrong.

The invariant is that the newest entry is never newer than the machine, and
`rewind` keeps it by moving onto the newest entry that is genuinely behind --
discarding the top first when the machine is standing exactly on it, loading it
as it is when the machine has run on past it. That second case is not an edge:
the window keeps a state every *other* frame, so on odd frames the top is one
frame back and a rewind that always discarded first would jump three. Running out
parks on the oldest frame kept rather than failing, since a key held too long is
a key held too long, and a rewind of nothing reloads nothing, so a caller may ask
on every frame without paying for the frames with nowhere to go.

`Rewind` is deliberately not reachable from `NES`, and not for tidiness:
`SaveStateCompletenessTests` walks everything the console can reach and scrambles
every array it finds, so a ring hanging off a chip would be shredded. It belongs
to whoever is driving the machine, which is also the honest place for it -- a
machine does not know how it got to where it is.

The window's interval of two is worth three things for one cost. Capture is half
as often, so it is 1.1 to 1.5ms a frame instead of 2.2 to 3.0. The same memory
holds twice the game. And since a tick gives back one state either way, the
rewind runs at twice speed -- undoing five seconds takes two and a half, which is
the difference between a feature and a chore. The cost is landing on even frames,
so letting go can be one frame from the exact moment somebody wanted. Fast
Forward shortens the wait like any other, which makes holding both a faster
reverse; the speed is read inside the branch rather than off the forward path's
snapshot, so it takes effect while the key is held rather than once it is let go.

The REPL stays at a state per frame, because it is the deterministic surface this
is checked on and `rewind 30` there has to mean thirty frames. `rewind on`, `run
90`, `rewind 30` lands on frame 60 with the same hash a plain `run 60` gives, on
nestest and on Super Mario Bros. alike -- a rewound machine is byte for byte the
machine that never went forward, and `RewindTests` asserts the whole state rather
than the picture, which stops being evidence as soon as a ROM settles down. It is
off unless asked for, since a headless run is usually a measurement and a
measurement should not quietly cost two milliseconds a frame.

`run.state.framesRewound` joins `startedFromPowerOn` in what has to be checked
before diffing two reports. A session that went back and played the same frames
again visited them with the machine in a state the frame counter no longer
describes, so its `frameChanges` and its sound are not a straight run's, and
nothing else in the document would say so. Additive, so `VERSION` stays 1.

The sound is the game's own, backwards. A rewind with the sound cut reads as a
fault rather than as rewinding, and the APU's sample ring is deliberately outside
the save state -- it is the queue between the chip and the card rather than the
chip -- so the sound has to be kept separately and fed on exactly the frames the
states are fed on, or the two drift and the rewind plays the wrong seconds. That
is why the APU drain moved above the capture. `RewindAudio` is one flat buffer of
fixed slots rather than a queue of arrays, because sixty arrays a second is
garbage the one thread with a deadline does not need to be making. It is written
without blocking, for the reason fast forward never blocks: there is no handing a
sound card audio faster than real time, and waiting for it would slow the rewind
to the speed of the thing being undone.

The marker is painted over the picture and never into it -- one in the
framebuffer would turn up in screenshots and in the frame hashes, where it would
be a lie about what the machine drew. Bottom left, because the top is where a NES
game keeps its score and its lives, and the first version sat on Super Mario
Bros.'s MARIO 000000.

Backspace, for the reason quick save and load are function keys: it sits in the
same physical place on every layout, which a letter does not. It is not one of
`KeyBindings`'s eight, since no wire in the controller port carries it and no
game can see it, so it is remapped in the file rather than through a dialog about
the eight things a NES pad had. `rewind.seconds=0` builds no ring and costs a
null check; anything over 300 is clamped, because an extra nought is a plausible
typo and an hour of save states is not a thing to discover by running out of
heap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dimiro1
dimiro1 merged commit a4ca236 into master Aug 20, 2026
2 checks passed
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