diff --git a/CLAUDE.md b/CLAUDE.md
index c88b887..6af0783 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -171,6 +171,31 @@ exit 2.
so `run.state.startedFromPowerOn` in the report is part of what to check before diffing two of them.
`--sram-in`/`--sram-out` do the same for battery RAM, in the `.sav` format other emulators read.
+### Going back rather than starting again
+
+`rewind on` keeps a state for every finished frame, and `rewind N` puts the machine back N of them.
+It is off unless asked for, because capturing costs two to three milliseconds a frame and a headless
+run is usually a measurement.
+
+```sh
+printf 'rewind on\nrun 90\nrewind 30\nquit\n' | java -jar $JAR --headless --rom ROM.nes --interactive
+```
+
+That lands on frame 60 with the same hash a plain `run 60` gives -- a rewound machine is byte for
+byte the machine that never went forward, which is the whole claim and is worth re-checking after
+touching any of it. `rewind on FRAMES` sizes the ring, the default being thirty seconds for the
+region; `rewind` on its own reports `capacity` and `rewindable`; `rewind off` drops the history.
+Running out is not an error, and the reply's `framesRewound` is how far it *actually* went.
+
+**`run.state.framesRewound` joins `startedFromPowerOn` in the list of things to check before diffing
+two runs.** 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. It is always present and 0 when nobody rewound.
+
+The window's ring is not this one: it keeps a state every *other* frame, which halves the cost,
+doubles the history for the memory, and gives back two frames per display tick so the rewind runs at
+twice speed. The REPL stays at one so that `rewind N` means N frames.
+
### Running a romhack
`--patch FILE` applies an IPS patch to the ROM before anything reads it as a cartridge. Repeatable,
diff --git a/README.md b/README.md
index 30dd1a5..480be37 100644
--- a/README.md
+++ b/README.md
@@ -63,11 +63,16 @@ page carries is never anything a build here has not already made.
| Select | Shift |
| Quick Save | F5 |
| Quick Load | F7 |
+| Rewind (hold) | Backspace |
| Screenshot | F12 |
+Rewind runs the game backwards through the last thirty seconds, sound and all, for as long as you
+hold the key; let go and it plays on from there. Fast Forward applies while you hold it, so the two
+together are a fast reverse.
+
**Settings > Controller...** remaps any of them. Click a button, press the key you want on it, and
that is it: there is no save button. Your choices land in `~/.mynes/config.properties`, along with
-the palette, screen size and fast forward speed:
+the palette, screen size, fast forward speed and how much rewind keeps:
```properties
video.palette=nesdev
@@ -77,10 +82,15 @@ video.screenshot.scale=1
emulation.region=auto
emulation.fast-forward=4x
audio.muted=false
+rewind.seconds=30
+rewind.key=VK_BACK_SPACE
controller1.a=VK_X
controller1.left=VK_LEFT
```
+`rewind.seconds=0` switches rewind off, which costs nothing at all; it is the one setting with no
+menu item, so that file is where it is remapped.
+
You can edit that file by hand instead. Key names are the `VK_` constants from
`java.awt.event.KeyEvent`, and an empty value leaves a button unbound. Anything missing or
misspelled falls back to its default and says so in the log.
diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Rewind.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Rewind.java
new file mode 100644
index 0000000..31ecb1e
--- /dev/null
+++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Rewind.java
@@ -0,0 +1,264 @@
+package com.github.dimiro1.mynes.state;
+
+import com.github.dimiro1.mynes.NES;
+import com.github.dimiro1.mynes.Region;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayDeque;
+
+/**
+ * The last few seconds of the machine, kept so that they can be run backwards.
+ *
+ * A ring that drops its oldest entry when it is full, and one invariant holding the whole thing up:
+ * the newest entry is never newer than the machine. {@link #capture} keeps that
+ * true going forwards, and {@link #rewind} keeps it true going backwards by moving onto the newest
+ * entry that is genuinely behind -- which means throwing the top away first when the top is exactly
+ * where the machine is standing, and loading it as it is when the machine has run on past it.
+ *
+ * Two things follow, and both are the answer somebody holding the key down wants. Running out parks
+ * on the oldest frame still kept rather than failing -- a key held too long is a key held too long,
+ * not a mistake. And a rewind of nothing is nothing: no state is reloaded, so a caller may ask on
+ * every frame without paying for the frames where there was nowhere to go.
+ *
+ *
The interval
+ *
+ * An entry is not necessarily a frame. {@link #Rewind(int, int)} takes a number of frames to leave
+ * between captures, and the window uses two, which buys three things at once: half the capture cost,
+ * twice the history for the same memory, and -- because a caller pops one entry per display tick --
+ * a rewind that runs backwards at twice speed instead of taking five seconds to undo five seconds.
+ * The price is landing on even frames only, which is half a frame of imprecision about where letting
+ * go of the key leaves the game.
+ *
+ * Going wider than a frame or two is where it would start to show. The states in between are not
+ * recoverable by any means -- nothing here re-emulates -- so the interval is exactly the granularity
+ * of the whole feature.
+ *
+ *
Why whole save states
+ *
+ * Every entry is a {@link SaveState}, unchanged and entire. That sounds expensive and is not: the
+ * body is gzipped, and a state taken mid-level comes to 2.5KB on Super Mario Bros., 7KB on Super
+ * Mario Bros. 2 and 13KB at the worst moment of Contra -- so the thirty seconds of NTSC history the
+ * window keeps is 4 to 22MB rather than the hundreds of megabytes the raw figures suggest. Most of a
+ * state is the framebuffer and the cartridge's RAM, and both of those are largely one repeated value.
+ *
+ * A diff against the previous frame would still be smaller, and it is not worth writing. This format
+ * is the one {@code SaveStateDivergenceTests} already proves round-trips from anywhere,
+ * mid-instruction and mid-scanline included; a second, cheaper, less tested way of putting a machine
+ * back is a second way for it to come back subtly wrong.
+ *
+ * The cartridge and region checks {@link SaveState#read} makes are trivially satisfied here, since
+ * the state is being put back into the machine it was taken from a moment ago. They cost a string
+ * comparison and they are left in: this is not a private format, it is the format.
+ *
+ * It also carries the framebuffer, which is what makes showing a rewound frame free. There is no
+ * re-emulation anywhere below -- the picture the display wants arrives with the state.
+ *
+ *
What it is not attached to
+ *
+ * Deliberately not reachable from {@link NES}, and not for tidiness. {@code SaveStateCompletenessTests}
+ * walks everything the console can reach and scrambles every array it finds; a ring of states hanging
+ * off a chip would be walked into and shredded. This belongs to whoever is driving the machine --
+ * the window's emulation thread, or a headless session -- which is also the honest place for it,
+ * since a machine does not know how it got to where it is.
+ *
+ * The history is wall-clock rather than causal, and rewinding through a reset or a loaded slot is
+ * allowed on purpose: those are things that happened, and the point of holding a key down is to
+ * undo what just happened. One consequence worth knowing about is that an external
+ * {@link SaveState#read} -- a quick-load, say -- leaves the newest entry describing a machine that
+ * is no longer there, and so breaks the invariant until the next {@link #capture}. The first rewind
+ * step discards that entry rather than loading it, so the damage is one frame of history rather than
+ * a machine put back to the wrong place.
+ */
+public final class Rewind {
+
+ /**
+ * Below this there is nothing to rewind to: one entry is where the machine already is.
+ */
+ public static final int MINIMUM_CAPACITY = 2;
+
+ /**
+ * Roughly what one gzipped state comes to, so the buffer a capture builds into rarely has to
+ * grow. Nothing depends on it being right.
+ */
+ private static final int EXPECTED_STATE_BYTES = 16 * 1024;
+
+ private final int capacity;
+ private final int interval;
+
+ /**
+ * Newest last, which is the end both {@link #capture} and {@link #rewind} work from.
+ */
+ private final ArrayDeque states;
+
+ /**
+ * How many more calls to {@link #capture} to wave through before taking one. Zero means the next
+ * one is due, which is why a fresh ring captures the moment it is asked to.
+ */
+ private int untilCapture;
+
+ /**
+ * Which frame the newest entry was taken on, so {@link #rewind} can tell whether the machine is
+ * standing on it or has run on past it. Meaningless while the ring is empty.
+ */
+ private long newestFrame = -1;
+
+ /**
+ * A state for every frame.
+ *
+ * @param capacity how many states to keep, at least {@link #MINIMUM_CAPACITY}.
+ */
+ public Rewind(final int capacity) {
+ this(capacity, 1);
+ }
+
+ /**
+ * @param capacity how many states to keep, at least {@link #MINIMUM_CAPACITY}.
+ * @param interval how many frames apart to take them. 1 for every frame; 2 for every other,
+ * which is what the window uses.
+ */
+ public Rewind(final int capacity, final int interval) {
+ if (capacity < MINIMUM_CAPACITY) {
+ throw new IllegalArgumentException(
+ "a rewind ring holds at least " + MINIMUM_CAPACITY + " states, since one of them"
+ + " is where the machine already is -- not " + capacity + ".");
+ }
+
+ if (interval < 1) {
+ throw new IllegalArgumentException(
+ "states are taken every " + MINIMUM_CAPACITY + " frames at the widest and every"
+ + " frame at the narrowest -- not every " + interval + ".");
+ }
+
+ this.capacity = capacity;
+ this.interval = interval;
+ this.states = new ArrayDeque<>(capacity);
+ }
+
+ /**
+ * How many states this keeps once it is full. Multiply by {@link #interval()} for the frames of
+ * history that comes to.
+ */
+ public int capacity() {
+ return capacity;
+ }
+
+ /**
+ * How many frames apart the states are taken.
+ */
+ public int interval() {
+ return interval;
+ }
+
+ /**
+ * How far back {@link #rewind} could go right now, which is one less than what is held: the
+ * newest entry is the machine as it stands.
+ */
+ public int rewindable() {
+ return Math.max(0, states.size() - 1);
+ }
+
+ /**
+ * Offers the machine to the ring, which writes it down if a state is due and counts the frame
+ * otherwise. The oldest is dropped when the ring is full.
+ *
+ * To be called at the end of every finished frame, including the ones it is going to throw away
+ * -- the counting is what an interval is made of. It is for the end of a frame and nowhere else:
+ * a capture taken part way through one is not wrong, since the format handles it, but a ring of
+ * them no longer counts in frames and every number a caller reports about it stops meaning what
+ * it says.
+ */
+ public void capture(final NES nes) {
+ if (untilCapture > 0) {
+ untilCapture--;
+ return;
+ }
+
+ var out = new ByteArrayOutputStream(EXPECTED_STATE_BYTES);
+
+ try {
+ SaveState.write(nes, out);
+ } catch (IOException e) {
+ // ByteArrayOutputStream does not fail, and neither does the deflater over it.
+ throw new AssertionError("a state written to memory cannot fail", e);
+ }
+
+ if (states.size() == capacity) {
+ states.removeFirst();
+ }
+
+ states.addLast(out.toByteArray());
+ newestFrame = nes.getPPU().getFrame();
+ untilCapture = interval - 1;
+ }
+
+ /**
+ * Puts the machine back {@code steps} states, or as far back as there is history for.
+ *
+ * The states gone past are forgotten rather than kept to go forwards again. Rewinding is how you
+ * take back what just happened; what happens instead is captured from here as it is played, and
+ * an undo of the undo would be a different feature with a different key on it.
+ *
+ * @param steps how many states to go back, each one {@link #interval()} frames. Zero or less, or
+ * an empty ring, moves nothing and leaves the machine untouched -- so there is no
+ * reload to pay for on a tick with nowhere to go.
+ * @return how many states it actually moved, which is fewer than asked for when the history ran
+ * out.
+ */
+ public int rewind(final NES nes, final int steps) {
+ if (states.isEmpty()) {
+ return 0;
+ }
+
+ // Whether the top entry is where the machine is standing or somewhere it has already run on
+ // from. Both happen: with an interval of one it is standing on it at every frame boundary,
+ // and with a wider one it is past it on all but every nth frame. A state loaded from outside
+ // -- a quick-load -- lands here too, and is treated as the ordinary case, so the first step
+ // back goes past the entry that no longer describes anything rather than onto it.
+ var pastTheTop = nes.getPPU().getFrame() > newestFrame;
+
+ var moved = Math.min(Math.max(steps, 0), rewindable() + (pastTheTop ? 1 : 0));
+
+ if (moved == 0) {
+ return 0;
+ }
+
+ // The first step is free when the machine has already left the top behind: there is a state
+ // to go back onto without discarding anything. Otherwise the top is discarded and the entry
+ // it was covering is loaded, which is the order that keeps the newest entry from describing
+ // a machine that is no longer there.
+ var discarded = pastTheTop ? moved - 1 : moved;
+
+ for (var i = 0; i < discarded; i++) {
+ states.removeLast();
+ }
+
+ try {
+ SaveState.read(nes, new ByteArrayInputStream(states.getLast()));
+ } catch (IOException e) {
+ throw new AssertionError("a state read from memory cannot fail", e);
+ }
+
+ // Read back off the machine rather than tracked: the state that has just landed put the
+ // frame counter exactly where it was when that state was taken.
+ newestFrame = nes.getPPU().getFrame();
+
+ // And the next capture is a whole interval away, so resuming lays the new timeline down on
+ // the same spacing the old one had rather than one frame out of step with it.
+ untilCapture = interval - 1;
+
+ return moved;
+ }
+
+ /**
+ * How many frames of history {@code seconds} comes to on this machine.
+ *
+ * Here rather than worked out by each front end, because the two would drift: it is 1803 frames
+ * on NTSC and 1500 on PAL for the same thirty seconds, and neither is thirty times a round
+ * number. A region is what says how long a frame is; this is the only place that has to know it.
+ */
+ public static int framesFor(final Region region, final int seconds) {
+ return (int) Math.round(seconds * 1e9 / region.frameNanos());
+ }
+}
diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/RewindTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/RewindTests.java
new file mode 100644
index 0000000..1cb21ec
--- /dev/null
+++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/RewindTests.java
@@ -0,0 +1,346 @@
+package com.github.dimiro1.mynes.state;
+
+import com.github.dimiro1.mynes.Cart;
+import com.github.dimiro1.mynes.Controller;
+import com.github.dimiro1.mynes.NES;
+import com.github.dimiro1.mynes.Region;
+import com.github.dimiro1.mynes.video.FrameAnalysis;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Going backwards.
+ *
+ * The claim being tested is a strong one and worth stating plainly: a machine that has been rewound
+ * to frame 60 is not approximately the machine that was at frame 60, it is that machine, byte for
+ * byte. So every test below compares the state's own bytes rather than only the picture -- the
+ * picture stops being evidence as soon as a ROM settles down, and the state bytes carry the cycle
+ * counters, the interrupt latches and every APU channel nobody can hear.
+ *
+ * That the format survives being written and read at all is {@link SaveStateDivergenceTests}'s job,
+ * and this deliberately does not repeat it. What is left here is the ring's own arithmetic: which
+ * entry a rewind of one lands on, what running out does, and what happens to the history when
+ * somebody plays on from the middle of it.
+ */
+class RewindTests {
+
+ /**
+ * Enough for every test here to keep everything it captures, so a ring that evicts is something
+ * a test asks for rather than something it stumbles into.
+ */
+ private static final int ROOMY = 400;
+
+ /**
+ * Where the run this is measured against starts pressing Start, and how long for. Copied from
+ * the frames {@code ReplTests} already establishes are enough for nestest to notice a button and
+ * redraw -- which is what makes the second timeline below genuinely a second timeline.
+ */
+ private static final int PRESS_AT = 60;
+ private static final int PRESS_FOR = 30;
+
+ /**
+ * Rewinding lands on the machine that was there, not on something close to it. The comparison is
+ * against a second machine that simply ran the shorter distance, which is the only definition of
+ * "where it was" that does not come from the thing being tested.
+ */
+ @Test
+ void rewindingGoesBackToTheFrameItLeft() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY);
+
+ // Arming captures at once, so the machine as it stands is the floor of the history rather
+ // than something the first frame has already moved off.
+ rewind.capture(nes);
+
+ for (var i = 0; i < 90; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ assertEquals(30, rewind.rewind(nes, 30));
+ assertEquals(60, nes.getPPU().getFrame());
+
+ var straight = load();
+ for (var i = 0; i < 60; i++) {
+ advanceFrame(straight);
+ }
+
+ assertEquals(
+ FrameAnalysis.hash(straight.getPPU().getFrameBuffer()),
+ FrameAnalysis.hash(nes.getPPU().getFrameBuffer()),
+ "the picture is the one that was on screen at frame 60");
+ assertArrayEquals(save(straight), save(nes), "and so is every field behind it");
+ }
+
+ /**
+ * One frame is the step the window takes, once per display tick, so it is the one that has to be
+ * exactly right: the entry on top is where the machine already is, and a rewind that loaded it
+ * would hold still while somebody held the key down.
+ */
+ @Test
+ void rewindingOneFrameMovesOneFrame() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY);
+ rewind.capture(nes);
+
+ for (var i = 0; i < 20; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ for (var frame = 19; frame >= 0; frame--) {
+ assertEquals(1, rewind.rewind(nes, 1));
+ assertEquals(frame, nes.getPPU().getFrame());
+ }
+ }
+
+ /**
+ * Held down too long, which is what a key does. Parking on the oldest frame still kept is the
+ * answer, rather than an error somebody holding a key cannot act on.
+ */
+ @Test
+ void rewindingStopsAtTheOldestFrameItKept() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(10);
+ rewind.capture(nes);
+
+ for (var i = 0; i < 50; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ assertEquals(9, rewind.rewind(nes, 99), "nine, not ninety-nine and not ten");
+ assertEquals(41, nes.getPPU().getFrame());
+ assertEquals(0, rewind.rewindable(), "and it is parked there");
+
+ assertEquals(0, rewind.rewind(nes, 1), "asking again from the floor moves nothing");
+ assertEquals(41, nes.getPPU().getFrame());
+ }
+
+ /**
+ * The frames rewound past are gone. Playing on writes a different future over them, and it is
+ * that future the next rewind walks back through -- otherwise holding the key twice would take
+ * somebody somewhere they had never been.
+ */
+ @Test
+ void theRingHoldsTheNewTimelineAfterResuming() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY);
+ rewind.capture(nes);
+
+ var untouched = new ArrayList();
+
+ for (var i = 0; i < PRESS_AT + PRESS_FOR; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ untouched.add(fingerprint(nes));
+ }
+
+ assertEquals(PRESS_FOR, rewind.rewind(nes, PRESS_FOR));
+ assertEquals(PRESS_AT, nes.getPPU().getFrame());
+
+ // The same frames again with a button the first run never saw, so what is captured over the
+ // top of the discarded entries really is a different machine.
+ nes.getController1().setButtons(Controller.BUTTON_START);
+
+ for (var i = 0; i < PRESS_FOR; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ nes.getController1().setButtons(0);
+
+ var played = fingerprint(nes);
+
+ assertNotEquals(untouched.getLast(), played,
+ "pressing Start has to have changed something, or there is only one timeline here"
+ + " and this proves nothing");
+
+ for (var i = 0; i < 10; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ assertEquals(10, rewind.rewind(nes, 10));
+ assertEquals(played, fingerprint(nes), "back onto the timeline that was actually played");
+ }
+
+ /**
+ * A machine nobody was recording, and one recorded for exactly one frame. Both have nowhere to
+ * go, and both have to leave the machine alone rather than reload the frame it is already on --
+ * which is what makes it free to ask on every frame of a run.
+ */
+ @Test
+ void aMachineThatNeverCapturedCannotRewind() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(10);
+
+ for (var i = 0; i < 5; i++) {
+ advanceFrame(nes);
+ }
+
+ var before = save(nes);
+
+ assertEquals(0, rewind.rewindable());
+ assertEquals(0, rewind.rewind(nes, 1));
+ assertArrayEquals(before, save(nes), "the machine was not touched");
+
+ rewind.capture(nes);
+
+ assertEquals(0, rewind.rewindable(), "one state is where the machine already is");
+ assertEquals(0, rewind.rewind(nes, 1));
+ assertArrayEquals(before, save(nes));
+ }
+
+ @Test
+ void aRingTooSmallToRewindIsRefused() {
+ for (var capacity : List.of(-1, 0, 1)) {
+ assertThrows(IllegalArgumentException.class, () -> new Rewind(capacity),
+ capacity + " states can never rewind");
+ }
+
+ assertEquals(2, new Rewind(2).capacity());
+ assertEquals(1, new Rewind(2).interval(), "a state for every frame unless asked otherwise");
+ assertThrows(IllegalArgumentException.class, () -> new Rewind(10, 0));
+ }
+
+ // ================================================================================= intervals
+
+ /**
+ * What the window keeps: a state every other frame, which is half the cost, twice the history
+ * for the memory, and a rewind that gives back two frames per tick instead of one.
+ */
+ @Test
+ void aWiderIntervalKeepsEveryOtherFrame() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY, 2);
+
+ // Frame 0, then 2, 4, ... 20. Eleven states over twenty-one frames.
+ rewind.capture(nes);
+
+ for (var i = 0; i < 20; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ assertEquals(10, rewind.rewindable(), "eleven states, ten of them to go back onto");
+
+ for (var frame = 18; frame >= 0; frame -= 2) {
+ assertEquals(1, rewind.rewind(nes, 1));
+ assertEquals(frame, nes.getPPU().getFrame(), "two frames a step");
+ }
+
+ assertEquals(0, rewind.rewind(nes, 1), "and the floor is the first frame it kept");
+ }
+
+ /**
+ * The odd frames in between are still somewhere to go back from. Standing on frame 21
+ * with the newest state taken on 20, the first step back is onto 20 rather than past it to 18 --
+ * otherwise letting go of the key would land somewhere nobody asked for.
+ */
+ @Test
+ void aFrameWithNoStateOfItsOwnStepsBackOntoTheNewestOne() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY, 2);
+ rewind.capture(nes);
+
+ for (var i = 0; i < 21; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ assertEquals(21, nes.getPPU().getFrame(), "an odd frame, and the newest state is on 20");
+
+ assertEquals(1, rewind.rewind(nes, 1));
+ assertEquals(20, nes.getPPU().getFrame(), "one frame, not three");
+
+ assertEquals(1, rewind.rewind(nes, 1));
+ assertEquals(18, nes.getPPU().getFrame(), "and two from there on");
+ }
+
+ /**
+ * Resuming lays the new timeline down on the same spacing the old one had. A capture that
+ * carried on counting from where the rewind interrupted it would put every state after it on the
+ * odd frames, and the two halves of the ring would disagree about what a step is.
+ */
+ @Test
+ void resumingKeepsTheStatesOnTheSameFrames() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY, 2);
+ rewind.capture(nes);
+
+ for (var i = 0; i < 20; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ rewind.rewind(nes, 5);
+ assertEquals(10, nes.getPPU().getFrame());
+
+ for (var i = 0; i < 6; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ assertEquals(16, nes.getPPU().getFrame());
+
+ assertEquals(1, rewind.rewind(nes, 1));
+ assertEquals(14, nes.getPPU().getFrame(), "still even, still two apart");
+ }
+
+ /**
+ * The two machines do not hold the same number of frames of the same wall-clock history, and
+ * neither number is thirty times something round.
+ */
+ @Test
+ void secondsOfHistoryCountDifferentlyOnTheTwoMachines() {
+ assertEquals(1803, Rewind.framesFor(Region.NTSC, 30));
+ assertEquals(1500, Rewind.framesFor(Region.PAL, 30));
+ }
+
+ // ================================================================================== internals
+
+ private static NES load() throws IOException {
+ var resource = "/nestest/nestest.nes";
+
+ try (var rom = RewindTests.class.getResourceAsStream(resource)) {
+ assertNotNull(rom, resource);
+ return new NES(Cart.load(rom.readAllBytes(), resource));
+ }
+ }
+
+ /**
+ * The whole machine, as something an assertion can print a difference between.
+ */
+ private static String fingerprint(final NES nes) throws IOException {
+ return Long.toHexString(FrameAnalysis.hash(nes.getPPU().getFrameBuffer()))
+ + " " + java.util.Arrays.hashCode(save(nes));
+ }
+
+ private static byte[] save(final NES nes) throws IOException {
+ var out = new ByteArrayOutputStream();
+
+ SaveState.write(nes, out);
+
+ return out.toByteArray();
+ }
+
+ private static void advanceFrame(final NES nes) {
+ var ppu = nes.getPPU();
+ var frame = ppu.getFrame();
+
+ do {
+ nes.tick();
+ } while (ppu.getFrame() == frame);
+ }
+}
diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java
index 497ba00..0986355 100644
--- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java
+++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java
@@ -5,6 +5,7 @@
import com.github.dimiro1.mynes.palette.NESPalette;
import com.github.dimiro1.mynes.palette.Palettes;
+import java.awt.event.KeyEvent;
import java.io.IOException;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
@@ -44,6 +45,30 @@ public final class Config {
private static final String FAST_FORWARD_KEY = "emulation.fast-forward";
private static final String MUTED_KEY = "audio.muted";
private static final String UNLIMITED_SPRITES_KEY = "hacks.unlimited-sprites";
+ private static final String REWIND_SECONDS_KEY = "rewind.seconds";
+ private static final String REWIND_KEY_KEY = "rewind.key";
+
+ /**
+ * How much history rewind keeps unless the file says otherwise. Long enough to undo the jump
+ * that went wrong and short enough that nobody has to think about the memory.
+ */
+ private static final int DEFAULT_REWIND_SECONDS = 30;
+
+ /**
+ * The ceiling, which is a guard against a typo rather than a considered limit. Five minutes of
+ * NTSC is 18,000 states; an extra nought on the end of the entry would be an hour of them, and
+ * the first anybody would know of it is the emulator running out of heap in the middle of a
+ * game.
+ */
+ private static final int MAX_REWIND_SECONDS = 300;
+
+ /**
+ * Backspace, for the reason the quick save and load keys are function keys: it sits in the same
+ * physical place on every keyboard layout, which a letter does not. It is also already the
+ * "undo the last thing" key everywhere else on the machine, and it is nowhere near the eight the
+ * game wants.
+ */
+ private static final int DEFAULT_REWIND_KEY = KeyEvent.VK_BACK_SPACE;
private static final String HEADER = """
# MyNES settings.
@@ -100,6 +125,17 @@ public final class Config {
# flickering -- which is a change to the picture and to nothing the game can see.
""";
+ private static final String REWIND_HEADER = """
+ # Holding the rewind key runs the game backwards through the last few seconds of it.
+ # rewind.seconds is how many of them to keep -- 0 switches the whole thing off, and
+ # anything over 300 is taken as 300. The cost is memory and about two milliseconds a
+ # frame, both of which a machine holding no history pays none of.
+ #
+ # rewind.key is a VK_ name from java.awt.event.KeyEvent, the same as the controller
+ # bindings below; an empty value leaves rewind with no key on it. There is no menu item
+ # for this one, so this file is where it is remapped.
+ """;
+
private KeyBindings keyBindings;
private NESPalette palette;
private NESPalette palPalette;
@@ -109,6 +145,8 @@ public final class Config {
private EmulationSpeed fastForwardSpeed;
private boolean muted;
private boolean unlimitedSprites;
+ private int rewindSeconds;
+ private int rewindKey;
private Config(
final KeyBindings keyBindings,
@@ -119,7 +157,9 @@ private Config(
final RegionSetting region,
final EmulationSpeed fastForwardSpeed,
final boolean muted,
- final boolean unlimitedSprites) {
+ final boolean unlimitedSprites,
+ final int rewindSeconds,
+ final int rewindKey) {
this.keyBindings = keyBindings;
this.palette = palette;
this.palPalette = palPalette;
@@ -129,6 +169,8 @@ private Config(
this.fastForwardSpeed = fastForwardSpeed;
this.muted = muted;
this.unlimitedSprites = unlimitedSprites;
+ this.rewindSeconds = rewindSeconds;
+ this.rewindKey = rewindKey;
}
/**
@@ -163,7 +205,49 @@ public static Config load(final Path path) {
regionFrom(properties),
fastForwardSpeedFrom(properties),
flagFrom(properties, MUTED_KEY),
- flagFrom(properties, UNLIMITED_SPRITES_KEY));
+ flagFrom(properties, UNLIMITED_SPRITES_KEY),
+ rewindSecondsFrom(properties),
+ KeyBindings.codeOf(
+ properties.getProperty(REWIND_KEY_KEY),
+ DEFAULT_REWIND_KEY,
+ REWIND_KEY_KEY));
+ }
+
+ /**
+ * How many seconds of history to keep.
+ *
+ * Two ways of being wrong and two different answers, because they are not the same mistake.
+ * Something that is not a number at all says nothing about what was wanted, so it falls back to
+ * the default like every other entry here. A number outside the range is a wish that can be
+ * granted approximately, so it is clamped -- and a negative one clamps to zero, which is the
+ * nearest thing to "less than none" the feature has.
+ */
+ private static int rewindSecondsFrom(final Properties properties) {
+ var value = properties.getProperty(REWIND_SECONDS_KEY);
+
+ if (value == null) {
+ return DEFAULT_REWIND_SECONDS;
+ }
+
+ int seconds;
+
+ try {
+ seconds = Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ logger.log(Level.WARNING, value.trim() + " is not a number of seconds, "
+ + REWIND_SECONDS_KEY + " falls back to " + DEFAULT_REWIND_SECONDS);
+ return DEFAULT_REWIND_SECONDS;
+ }
+
+ var clamped = Math.clamp(seconds, 0, MAX_REWIND_SECONDS);
+
+ if (clamped != seconds) {
+ logger.log(Level.WARNING,
+ REWIND_SECONDS_KEY + " is " + seconds + ", which is outside 0 to "
+ + MAX_REWIND_SECONDS + " -- keeping " + clamped + " seconds");
+ }
+
+ return clamped;
}
/**
@@ -279,6 +363,16 @@ public void save(final Path path) throws IOException {
.append(unlimitedSprites)
.append("\n\n");
+ text.append(REWIND_HEADER)
+ .append(REWIND_SECONDS_KEY)
+ .append('=')
+ .append(rewindSeconds)
+ .append('\n')
+ .append(REWIND_KEY_KEY)
+ .append('=')
+ .append(KeyBindings.nameOf(rewindKey))
+ .append("\n\n");
+
keyBindings.appendTo(text);
var parent = path.getParent();
@@ -396,4 +490,35 @@ public boolean unlimitedSprites() {
public void setUnlimitedSprites(final boolean unlimitedSprites) {
this.unlimitedSprites = unlimitedSprites;
}
+
+ /**
+ * How many seconds of the game to keep so it can be run backwards, or 0 for a machine that keeps
+ * none and so costs nothing.
+ *
+ * Seconds rather than frames because that is the question somebody is actually asking, and
+ * because the answer in frames depends on which machine the cartridge turns out to run on --
+ * which is not known until one is loaded.
+ */
+ public int rewindSeconds() {
+ return rewindSeconds;
+ }
+
+ public void setRewindSeconds(final int rewindSeconds) {
+ this.rewindSeconds = Math.clamp(rewindSeconds, 0, MAX_REWIND_SECONDS);
+ }
+
+ /**
+ * The key held down to run the game backwards, or {@link KeyBindings#UNBOUND} for nobody's key.
+ *
+ * Not one of {@link KeyBindings}'s eight, because it is not a button: no wire in the controller
+ * port carries it and no game can see it. It is remapped by editing the file rather than through
+ * Settings > Controller..., which is a dialog about the eight things a NES pad had.
+ */
+ public int rewindKey() {
+ return rewindKey;
+ }
+
+ public void setRewindKey(final int rewindKey) {
+ this.rewindKey = rewindKey;
+ }
}
diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/EmulatorRunner.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/EmulatorRunner.java
index cdfbd1a..c27f1ab 100644
--- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/EmulatorRunner.java
+++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/EmulatorRunner.java
@@ -2,6 +2,8 @@
import com.github.dimiro1.mynes.NES;
import com.github.dimiro1.mynes.debug.Debugger;
+import com.github.dimiro1.mynes.state.Rewind;
+import org.jetbrains.annotations.Nullable;
import javax.swing.SwingUtilities;
import java.lang.System.Logger;
@@ -15,6 +17,12 @@
* {@link ScreenComponent} -- all of them at normal speed, sixty a second of them when fast
* forwarding -- and the sound that went with them to an {@link AudioOutput}.
*
+ * It also runs the machine the other way. Every other finished frame is written into a
+ * {@link Rewind} ring, and while the rewind key is held the loop pops one of them per display tick
+ * instead of clocking anything -- so the game goes backwards at twice speed without a single
+ * instruction being re-executed, because the picture travels inside the state. Fast Forward applies
+ * to that wait like any other, which makes holding both keys a faster reverse still.
+ *
* Emulation cannot happen on the event dispatch thread: a machine that never stops running would
* never let the EDT paint a menu. So this is the only thread that touches the NES, and the only
* thing it touches on the UI side is {@link ScreenComponent#present(int[])}, which is written to
@@ -58,6 +66,22 @@ public class EmulatorRunner {
*/
private static final int AUDIO_BUFFER_SAMPLES = 4096;
+ /**
+ * How many frames apart the rewind states are taken, and so how many frames each display tick
+ * gives back while the key is held.
+ *
+ * Two rather than one, which is three improvements for one cost. The capture is half as often,
+ * so it takes a little over a millisecond a frame instead of nearly three. The same memory holds
+ * twice as much 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 rather than five, which
+ * is the difference between a feature and a chore.
+ *
+ * The cost is that letting go of the key lands on an even frame, so it can be one frame away
+ * from the exact moment somebody wanted. At sixty frames a second that is sixteen milliseconds
+ * of a game they are about to play differently anyway.
+ */
+ private static final int REWIND_INTERVAL = 2;
+
private final NES nes;
private final ScreenComponent screen;
private final AudioOutput audio = new AudioOutput();
@@ -89,8 +113,30 @@ public class EmulatorRunner {
*/
private final Debugger debugger;
+ /**
+ * The last few seconds of the machine, or null when the setting asked for none.
+ *
+ * Owned by the runner rather than by the window, unlike the debugger: a history belongs to the
+ * machine that lived it, and carrying one across a power cycle would let somebody rewind into a
+ * game that had already been switched off and on again.
+ */
+ private final @Nullable Rewind rewind;
+
+ /**
+ * The sound that went with the history, kept alongside it and never without it. Null exactly
+ * when {@link #rewind} is.
+ */
+ private final @Nullable RewindAudio rewindAudio;
+
private volatile boolean running;
+ /**
+ * Whether the rewind key is being held. Written by the event dispatch thread and read here, so
+ * the loop picks it up at the next frame boundary rather than mid-frame -- the same handoff as
+ * {@link #paused} and for the same reason.
+ */
+ private volatile boolean rewinding;
+
/**
* Written by the event dispatch thread when somebody uses the Pause item, and by this thread
* when {@link #halt} stops the machine at a breakpoint. Two writers, which is safe because it is
@@ -113,11 +159,35 @@ public class EmulatorRunner {
private Thread thread;
- public EmulatorRunner(final NES nes, final ScreenComponent screen, final Debugger debugger) {
+ /**
+ * @param rewindFrames how many frames of history to keep so the machine can be run backwards
+ * through them, or 0 for a machine that keeps none -- which costs one null
+ * check a frame and nothing else. Frames rather than states: the ring holds
+ * one state per {@link #REWIND_INTERVAL} of them.
+ */
+ public EmulatorRunner(
+ final NES nes,
+ final ScreenComponent screen,
+ final Debugger debugger,
+ final int rewindFrames) {
this.nes = nes;
this.screen = screen;
this.debugger = debugger;
this.frameNanos = nes.getRegion().frameNanos();
+
+ var states = rewindFrames / REWIND_INTERVAL;
+
+ if (states >= Rewind.MINIMUM_CAPACITY) {
+ this.rewind = new Rewind(states, REWIND_INTERVAL);
+
+ // Counted in frames rather than states, because sound is not something there can be
+ // every other one of: the ring has to hold the frames in between as well, or the rewind
+ // would play half the seconds it was showing.
+ this.rewindAudio = new RewindAudio(states * REWIND_INTERVAL);
+ } else {
+ this.rewind = null;
+ this.rewindAudio = null;
+ }
}
/**
@@ -196,6 +266,17 @@ public boolean isPaused() {
return paused;
}
+ /**
+ * Runs the machine backwards for as long as this is true, one frame of history per display tick.
+ * Takes effect within a frame.
+ *
+ * A no-op on a machine keeping no history, so the key can be wired up unconditionally and the
+ * setting decides whether anything happens.
+ */
+ public void setRewinding(final boolean rewinding) {
+ this.rewinding = rewinding;
+ }
+
/**
* Told whenever the machine stops at a breakpoint, a watchpoint, a step or a Break, on the event
* dispatch thread and with the machine already stopped.
@@ -277,19 +358,27 @@ private void run() {
try {
audio.open();
+ // The floor of the history is the machine as it was switched on, so that rewinding all
+ // the way back lands on the power-on screen rather than on whatever the first frame of
+ // the game happened to be.
+ if (rewind != null) {
+ rewind.capture(nes);
+ }
+
var speed = this.speed;
var deadline = System.nanoTime();
var nextPresent = deadline + frameNanos;
var lastFrame = ppu.getFrame();
var wasPaused = false;
+ var wasRewinding = false;
while (running) {
runPendingCommands();
// Normally a no-op -- nothing has been clocked since this was last assigned. It
- // matters when a command has just loaded a save state, which can move the frame
- // counter backwards: the loop below waits for the counter to *change*, so a stale
- // value here would satisfy it after a single tick and present a torn frame.
+ // matters when the frame counter has just moved backwards, which both a loaded save
+ // state and a rewound frame do: the loop below waits for the counter to *change*, so
+ // a stale value here would satisfy it after a single tick and present a torn frame.
lastFrame = ppu.getFrame();
// Asked before the pause is looked at, because a step is the one thing that runs a
@@ -313,6 +402,76 @@ private void run() {
continue;
}
+ // After the pause branch, so pause wins: a frozen machine that could still be
+ // rewound would be two ideas about what the screen is showing. And guarded against
+ // stepping for the reason the pause branch is, since a step is the one thing that
+ // runs a machine that is not running.
+ if (rewinding && rewind != null && !stepping) {
+ if (!wasRewinding) {
+ // What the card is holding is up to a tenth of a second of a game that is
+ // now running the other way. Dropped for the reason a pause drops it.
+ audio.flush();
+ screen.setRewinding(true);
+ wasRewinding = true;
+ }
+
+ // Read here rather than taken from the snapshot below, which is only refreshed
+ // on the forward path: reaching for Fast Forward without letting go of rewind is
+ // how the game runs backwards at speed, and it has to take effect while it is
+ // being held rather than once it has been let go of.
+ var rewindSpeed = this.speed;
+ var wasOn = ppu.getFrame();
+ var moved = rewind.rewind(nes, 1);
+
+ if (moved > 0) {
+ // The frames that step actually gave back, which is two most of the time and
+ // one on the first step off a frame with no state of its own. Counted rather
+ // than assumed, so the sound is exactly the sound of the frames the picture
+ // has just gone back over.
+ var given = (int) (wasOn - ppu.getFrame());
+
+ // Backwards, and at whatever rate the rewind is running -- so two frames of
+ // it are handed over in the time the card plays one. Never blocking, for the
+ // reason fast forward never blocks: there is no way to give a sound card
+ // audio faster than real time, and waiting for it would slow the rewind down
+ // to the speed of the thing being undone. What does not fit is dropped, so
+ // this comes out chopped, which is very much what rewinding sounds like.
+ audio.write(samples, rewindAudio.take(given, samples), false);
+
+ // Nothing is re-emulated: the picture arrives with the state. What is left
+ // is deciding whether to hand it over, and that is the forward path's
+ // arithmetic unchanged -- otherwise UNLIMITED would ask the display for
+ // several thousand pictures a second while it drained the ring.
+ var now = System.nanoTime();
+
+ if (rewindSpeed == EmulationSpeed.NORMAL || now - nextPresent >= 0) {
+ screen.present(ppu.getFrameBuffer());
+
+ nextPresent += frameNanos;
+ if (nextPresent - now < 0) {
+ nextPresent = now + frameNanos;
+ }
+ }
+ }
+
+ // A ring that has run out waits a whole frame whatever the speed. There is
+ // nothing left to go back to, so the oldest picture simply stays up -- and
+ // UNLIMITED, which does not wait at all, would otherwise spin against it.
+ LockSupport.parkNanos(
+ moved > 0 ? rewindSpeed.frameNanos(nes.getRegion()) : frameNanos);
+ deadline = System.nanoTime();
+ continue;
+ }
+
+ if (wasRewinding) {
+ // The other edge, and the same reasoning: the card is holding up to a tenth of
+ // a second of a game running backwards, which stopped being true the moment the
+ // key came up. What comes out of the speaker should be what is on the screen.
+ audio.flush();
+ screen.setRewinding(false);
+ wasRewinding = false;
+ }
+
// Skipped while stepping: the machine is still stopped, the card was emptied when
// it stopped, and a speed schedule belongs to a loop that is running.
if (!paused) {
@@ -344,6 +503,23 @@ private void run() {
var completed = ppu.getFrame() != lastFrame;
+ // Drained up here rather than at the two places below that used to do it, because
+ // the rewind ring has to be given the sound of a frame before anything decides
+ // whether that frame's sound is going to be played. A frame that stopped part way
+ // through is left alone, exactly as it was: there is no finished frame of sound in
+ // it, and the APU's own ring holds several frames' worth of slack.
+ var sampleCount = completed ? apu.drainSamples(samples) : 0;
+
+ // Every frame that finished, wherever it finished -- stepped, halted, fast
+ // forwarded. One place, above everything below that might skip the rest of the
+ // loop, because the ring's newest entry has to describe the machine as it stands --
+ // and because the two rings must be fed on exactly the same frames or the sound
+ // would come from a different second of the game than the picture.
+ if (completed && rewind != null) {
+ rewind.capture(nes);
+ rewindAudio.capture(samples, sampleCount);
+ }
+
if (stop != null) {
halt(stop);
}
@@ -359,9 +535,10 @@ private void run() {
if (stop != null) {
// A stepped or halted frame still goes on the screen. Its sound does not: one
// frame of it played on its own is a click, and a machine stepped a frame at a
- // time would be a metronome of them. Drained rather than left, so the ring does
- // not carry this frame across the stop and play it on the far side.
- apu.drainSamples(samples);
+ // time would be a metronome of them. It was drained above rather than left, so
+ // the APU's ring does not carry this frame across the stop and play it on the
+ // far side -- and the rewind ring kept it, so going back over a stepped frame
+ // still has its sound.
screen.present(ppu.getFrameBuffer());
continue;
}
@@ -372,7 +549,7 @@ private void run() {
// going to be dropped anyway. Fast forwarding cannot block -- there is no way to
// hand a sound card audio faster than real time -- so what does not fit is lost,
// and fast forward sounds chopped rather than sped up.
- audio.write(samples, apu.drainSamples(samples), speed == EmulationSpeed.NORMAL);
+ audio.write(samples, sampleCount, speed == EmulationSpeed.NORMAL);
// Fast forward finishes frames faster than any display can show them, so most of
// them are dropped rather than handed over. A frame nobody will see still costs a
@@ -419,6 +596,10 @@ private void run() {
logger.log(Level.ERROR, "emulation failed at frame " + ppu.getFrame(), t);
} finally {
audio.close();
+
+ // A machine torn down mid-rewind would otherwise leave the marker painted over the next
+ // one -- or over an empty window, if this was the last.
+ screen.setRewinding(false);
}
logger.log(Level.INFO, "emulation stopped");
diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java
index 3bf0003..693f228 100644
--- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java
+++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java
@@ -9,6 +9,7 @@
import com.github.dimiro1.mynes.debug.Debugger;
import com.github.dimiro1.mynes.patch.IPSPatch;
import com.github.dimiro1.mynes.state.BatteryRAM;
+import com.github.dimiro1.mynes.state.Rewind;
import com.github.dimiro1.mynes.state.SaveState;
import com.github.dimiro1.mynes.state.SaveStateException;
import com.github.dimiro1.mynes.ui.chrviewer.CHRViewerFrame;
@@ -199,6 +200,10 @@ public GameUIFrame() {
config = Config.load(Config.DEFAULT_PATH);
keyboardInput = new KeyboardInput(this, config.keyBindings());
+
+ // Once, unlike the bindings: there is no dialog that moves this one, only the file.
+ keyboardInput.setRewindKey(config.rewindKey());
+
screen.setPalette(config.palette(currentRegion()));
// Before init()'s pack(), so the window opens at the size it was left at rather than opening
@@ -1253,9 +1258,17 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) {
machineMenuPause.setSelected(false);
machineMenuFastForward.setSelected(false);
- runner = new EmulatorRunner(nes, screen, debugger);
+ // Seconds is what the setting says and frames is what a ring holds, and only here is it
+ // known which machine the cartridge turned out to run on -- 1803 frames for thirty seconds
+ // of NTSC against 1500 of PAL. Zero seconds builds no ring at all.
+ runner = new EmulatorRunner(nes, screen, debugger,
+ Rewind.framesFor(nes.getRegion(), config.rewindSeconds()));
runner.setStopListener(this::stopped);
+ // Per machine, like the controller above and for the same reason: each one keeps its own
+ // history, and the key must not still be rewinding a game that has been switched off.
+ keyboardInput.setRewind(runner::setRewinding);
+
if (debuggerFrame != null) {
debuggerFrame.setMachine(nes, runner);
}
diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/RewindAudio.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/RewindAudio.java
new file mode 100644
index 0000000..04604cf
--- /dev/null
+++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/RewindAudio.java
@@ -0,0 +1,113 @@
+package com.github.dimiro1.mynes.ui;
+
+import com.github.dimiro1.mynes.APU;
+
+/**
+ * The sound that went with the last few seconds, so that rewinding can play it backwards.
+ *
+ * A rewind with the sound cut is a rewind that feels broken: the picture is doing something and the
+ * speaker has stopped. Playing the game's own audio in reverse is the thing that reads as rewinding
+ * rather than as a fault -- it is what Braid does, and what a tape did before that.
+ *
+ * Beside {@link com.github.dimiro1.mynes.state.Rewind} rather than inside it, and in this module
+ * rather than the core, because it is a fact about having a sound card. A headless run has no
+ * speaker and a save state has no sound in it: the APU's sample ring is deliberately left out of the
+ * format, on the grounds that it is the queue between the chip and the card rather than the chip.
+ * That is the right decision and this is what it costs -- the sound has to be kept separately,
+ * fed on exactly the frames the state ring is fed on, or the two would drift and the rewind would
+ * play the wrong seconds.
+ *
+ *
Why it is one array
+ *
+ * A frame is about 735 samples, and the obvious shape -- a queue of {@code short[]} -- would make
+ * sixty arrays a second of garbage on the emulation thread, which is the one thread with a deadline.
+ * So it is one flat buffer of fixed slots with the frames written round it, and after the
+ * constructor nothing here allocates anything. Thirty seconds costs about 3MB, which is small beside
+ * the states it accompanies.
+ */
+final class RewindAudio {
+
+ /**
+ * How big one frame's slot is. Sized for PAL, which fits 882 samples into its longer frame
+ * against NTSC's 735 -- one number for both, since the few kilobytes it wastes on an NTSC
+ * machine are not worth a second code path.
+ */
+ private static final int SAMPLES_PER_FRAME = APU.SAMPLE_RATE / 50 + 16;
+
+ private final int capacity;
+ private final short[] samples;
+ private final int[] counts;
+
+ /**
+ * The slot the newest frame went into, and how many slots hold anything. Together they are the
+ * ring: there is no separate read cursor because the only reader walks backwards from the
+ * newest, which is what rewinding is.
+ */
+ private int newest = -1;
+ private int size;
+
+ /**
+ * @param capacity how many frames of sound to keep. The same number of frames the states cover,
+ * so the two run out together.
+ */
+ RewindAudio(final int capacity) {
+ this.capacity = capacity;
+ this.samples = new short[capacity * SAMPLES_PER_FRAME];
+ this.counts = new int[capacity];
+ }
+
+ /**
+ * Writes down a frame's worth of sound, dropping the oldest when full.
+ *
+ * To be called for every finished frame, including the silent ones and the ones whose
+ * sound is not being played. A frame missing from here is a frame the rewind would take its
+ * sound from the wrong side of.
+ */
+ void capture(final short[] from, final int count) {
+ newest = (newest + 1) % capacity;
+
+ // A frame that ran long can produce more than a slot holds. Losing the tail of it is a
+ // handful of samples out of a rewind that is already a scrub.
+ var kept = Math.clamp(count, 0, SAMPLES_PER_FRAME);
+
+ System.arraycopy(from, 0, samples, newest * SAMPLES_PER_FRAME, kept);
+ counts[newest] = kept;
+
+ if (size < capacity) {
+ size++;
+ }
+ }
+
+ /**
+ * Takes the newest {@code frames} frames back off the ring and lays them into {@code into}
+ * newest first, each one backwards -- which is those frames played in reverse.
+ *
+ * Taken rather than read, so that the sound goes away with the states it belongs to and cannot
+ * be played twice.
+ *
+ * @return how many samples landed in {@code into}, which is 0 once the history has run out.
+ */
+ int take(final int frames, final short[] into) {
+ var written = 0;
+
+ for (var i = 0; i < frames && size > 0; i++) {
+ var base = newest * SAMPLES_PER_FRAME;
+
+ for (var sample = counts[newest] - 1; sample >= 0 && written < into.length; sample--) {
+ into[written++] = samples[base + sample];
+ }
+
+ newest = (newest + capacity - 1) % capacity;
+ size--;
+ }
+
+ return written;
+ }
+
+ /**
+ * How many frames of sound are held.
+ */
+ int size() {
+ return size;
+ }
+}
diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/ScreenComponent.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/ScreenComponent.java
index 0241422..16240e1 100644
--- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/ScreenComponent.java
+++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/ScreenComponent.java
@@ -30,6 +30,22 @@
* where the reason for it is written down.
*/
public class ScreenComponent extends JComponent {
+
+ /**
+ * How tall the rewind marker is as a fraction of the picture, and how small it is allowed to get
+ * before the fraction stops applying -- at 1x an eighteenth of the picture is twelve pixels, and
+ * anything under about six stops reading as a triangle at all.
+ */
+ private static final int MARKER_HEIGHT_DIVISOR = 18;
+ private static final int MARKER_MINIMUM = 6;
+
+ /**
+ * Translucent, because it sits over a game somebody is trying to see. White reads against nearly
+ * every NES palette entry; the shadow is what carries it over the few it does not.
+ */
+ private static final Color MARKER_FILL = new Color(255, 255, 255, 210);
+ private static final Color MARKER_SHADOW = new Color(0, 0, 0, 140);
+
private final Object frameLock = new Object();
private final BufferedImage image = new BufferedImage(
PPU.SCREEN_WIDTH, PPU.SCREEN_HEIGHT, BufferedImage.TYPE_INT_RGB);
@@ -57,6 +73,14 @@ public class ScreenComponent extends JComponent {
private int[] palette = Palettes.defaultPalette().colours();
+ /**
+ * Whether to draw the rewind marker over the picture. Written by the emulation thread and read
+ * by the event dispatch thread when it paints, which is what {@code volatile} is here for; it is
+ * outside {@link #frameLock} on purpose, since a marker that appeared a frame late would be
+ * nobody's problem and holding the lock for it would be.
+ */
+ private volatile boolean rewinding;
+
public ScreenComponent() {
setScale(ScreenScale.defaultScale());
setOpaque(true);
@@ -96,6 +120,28 @@ public void present(final int[] frameBuffer) {
repaint();
}
+ /**
+ * Draws the rewind marker over the picture, or stops.
+ *
+ * Called from the emulation thread, like {@link #present(int[])}, and by the same rule: that is
+ * the thread that knows whether the machine is actually going backwards, which is not the same
+ * question as whether the key is down -- a paused machine, or a history that has run out, is
+ * a key held with nothing happening.
+ *
+ * Over the picture rather than in it. What the PPU drew is what the PPU drew, and a marker
+ * painted into the framebuffer would end up in screenshots and in the frame hashes, where it
+ * would be a lie about the machine.
+ */
+ public void setRewinding(final boolean rewinding) {
+ if (this.rewinding == rewinding) {
+ return;
+ }
+
+ this.rewinding = rewinding;
+
+ repaint();
+ }
+
/**
* The picture as it stands, magnified {@code scale} times, or null before the first frame.
*
@@ -183,8 +229,55 @@ protected void paintComponent(final Graphics g) {
PPU.SCREEN_WIDTH, FrameRenderer.VISIBLE_BOTTOM,
null);
}
+
+ if (rewinding) {
+ drawRewindMarker(g2, x, y, height);
+ }
+
} finally {
g2.dispose();
}
}
+
+ /**
+ * A pair of triangles pointing back the way the game is going, in the corner a video recorder
+ * used to put them.
+ *
+ * The bottom left, which is the one decision here worth explaining: the top is where a
+ * NES game keeps its score, its lives and its timer, and a marker over Super Mario Bros.'s
+ * MARIO 000000 is a marker in the way. Almost nothing puts a status bar along the bottom.
+ *
+ * Sized off the picture rather than off the window, so it stays the same size relative to the
+ * game at every scale, and drawn twice -- once offset in black -- because a translucent white
+ * mark on its own disappears into a bright sky.
+ */
+ private static void drawRewindMarker(
+ final Graphics2D g2, final int x, final int y, final int height) {
+ var size = Math.max(MARKER_MINIMUM, height / MARKER_HEIGHT_DIVISOR);
+ var arrow = size * 3 / 4;
+ var gap = Math.max(1, size / 5);
+ var margin = size * 2 / 3;
+ var offset = Math.max(1, size / 12);
+
+ var left = x + margin;
+ var top = y + height - margin - size;
+
+ g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+
+ // The shadow first and the mark over it, so the offset copy reads as a shadow rather than
+ // as a third triangle.
+ for (var shadow = 1; shadow >= 0; shadow--) {
+ g2.setColor(shadow == 1 ? MARKER_SHADOW : MARKER_FILL);
+
+ for (var triangle = 0; triangle < 2; triangle++) {
+ var start = left + triangle * (arrow + gap) + shadow * offset;
+ var line = top + shadow * offset;
+
+ g2.fillPolygon(
+ new int[]{start + arrow, start + arrow, start},
+ new int[]{line, line + size, line + size / 2},
+ 3);
+ }
+ }
+ }
}
diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyBindings.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyBindings.java
index 867180f..abe0d0e 100644
--- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyBindings.java
+++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyBindings.java
@@ -155,29 +155,49 @@ public static KeyBindings from(final Properties properties) {
var keys = new EnumMap