Run the machine backwards, from a held Backspace or a REPL rewind - #39
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rewind is a ring of whole save states —
SaveStatealready 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 offdo the same from the REPL, which is where it is checked:rewind on,run 90,rewind 30lands on frame 60 with the same frame hash a plainrun 60gives, 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=0in~/.mynes/config.propertiesbuilds no ring at all.run.state.framesRewoundjoinsstartedFromPowerOnas something to check before diffing two reports (additive, so the report version stays 1); there are no new dependencies, andmvn -B testandscripts/smoke-distribution.share green.🤖 Generated with Claude Code