From 3ecb2d7217785d9b1c20b11cedb91785a50224f2 Mon Sep 17 00:00:00 2001 From: dimiro1 Date: Thu, 20 Aug 2026 22:01:25 +0200 Subject: [PATCH] Run the machine backwards, from a held Backspace or a REPL rewind 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) --- CLAUDE.md | 25 ++ README.md | 12 +- .../github/dimiro1/mynes/state/Rewind.java | 264 +++++++++++++ .../dimiro1/mynes/state/RewindTests.java | 346 ++++++++++++++++++ .../com/github/dimiro1/mynes/ui/Config.java | 129 ++++++- .../dimiro1/mynes/ui/EmulatorRunner.java | 197 +++++++++- .../github/dimiro1/mynes/ui/GameUIFrame.java | 15 +- .../github/dimiro1/mynes/ui/RewindAudio.java | 113 ++++++ .../dimiro1/mynes/ui/ScreenComponent.java | 93 +++++ .../dimiro1/mynes/ui/input/KeyBindings.java | 48 ++- .../dimiro1/mynes/ui/input/KeyboardInput.java | 102 +++++- .../github/dimiro1/mynes/ui/ConfigTests.java | 88 +++++ .../dimiro1/mynes/ui/EmulatorRunnerTests.java | 88 ++++- .../dimiro1/mynes/ui/RewindAudioTests.java | 141 +++++++ .../mynes/ui/ScreenComponentTests.java | 98 ++++- .../mynes/ui/debugger/DebuggerFrameTests.java | 2 +- .../github/dimiro1/mynes/headless/Repl.java | 66 ++++ .../github/dimiro1/mynes/headless/Report.java | 6 + .../dimiro1/mynes/headless/Session.java | 119 ++++++ .../mynes/headless/HeadlessRunTests.java | 22 ++ .../dimiro1/mynes/headless/ReplTests.java | 100 +++++ 21 files changed, 2041 insertions(+), 33 deletions(-) create mode 100644 mynes-core/src/main/java/com/github/dimiro1/mynes/state/Rewind.java create mode 100644 mynes-core/src/test/java/com/github/dimiro1/mynes/state/RewindTests.java create mode 100644 mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/RewindAudio.java create mode 100644 mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/RewindAudioTests.java 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(Button.class); for (var button : Button.values()) { - keys.put(button, parse(properties.getProperty(button.propertyKey()), button)); + keys.put( + button, + codeOf( + properties.getProperty(button.propertyKey()), + DEFAULTS.get(button), + button.propertyKey())); } return new KeyBindings(keys); } - private static int parse(final @Nullable String value, final Button button) { - if (value == null) { - return DEFAULTS.get(button); + /** + * Reads one {@code VK_} name out of the config file, the way every entry in it is read: nothing + * is trusted, and a bad one costs its own setting rather than the startup. + *

+ * Public and not about the eight buttons, because the file has grown other keys since -- the + * rewind key is one -- and the alternative is a second reflection-built table somewhere else + * that disagrees with this one about what this JDK calls a key. + * + * @param name the value as it appears in the file, or null if the entry is not there. + * @param fallback what a missing or unreadable entry means. {@link #UNBOUND} for a key that is + * allowed to be nothing. + * @param setting the property this came from, so the log says which line to go and fix. + * @return the key code, or {@link #UNBOUND} for an entry deliberately emptied out. + */ + public static int codeOf( + final @Nullable String name, final int fallback, final String setting) { + if (name == null) { + return fallback; } - var name = value.trim(); - if (name.isEmpty()) { + var trimmed = name.trim(); + if (trimmed.isEmpty()) { // Deliberately unbound. Someone who would rather not give up a key for Select can // empty the entry out and say so. return UNBOUND; } - var code = CODES_BY_NAME.get(name); + var code = CODES_BY_NAME.get(trimmed); if (code == null) { - logger.log(Level.WARNING, name + " is not a key name, " - + button.propertyKey() + " falls back to its default"); - return DEFAULTS.get(button); + logger.log(Level.WARNING, trimmed + " is not a key name, " + + setting + " falls back to its default"); + return fallback; } return code; @@ -196,12 +216,16 @@ public void appendTo(final StringBuilder text) { for (var button : Button.values()) { text.append(button.propertyKey()) .append('=') - .append(nameFor(keyFor(button))) + .append(nameOf(keyFor(button))) .append('\n'); } } - private static String nameFor(final int code) { + /** + * How a key code is spelled back into the config file, and the other half of {@link #codeOf}. + * Empty for a key that is nothing, which is what reads back as unbound. + */ + public static String nameOf(final int code) { if (code == UNBOUND) { return ""; } diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyboardInput.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyboardInput.java index 71cced7..654a86d 100644 --- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyboardInput.java +++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/input/KeyboardInput.java @@ -8,6 +8,7 @@ import java.awt.Window; import java.awt.event.InputEvent; import java.awt.event.KeyEvent; +import java.util.function.Consumer; /** * The keyboard, wired to player one's controller. @@ -47,6 +48,27 @@ public final class KeyboardInput implements KeyEventDispatcher { */ private int pressed; + /** + * The key that runs the game backwards while it is held, or {@link KeyBindings#UNBOUND}. + *

+ * Beside the bindings rather than in them, because it is not a button: the controller port has + * no wire for it and no game can be told it was pressed. What it drives is the loop that clocks + * the machine, which is why it goes somewhere else entirely. + */ + private int rewindKey = KeyBindings.UNBOUND; + + /** + * Where "the rewind key is down" is sent, which is the emulation thread's own switch. Null + * whenever there is no machine to rewind. + */ + private @Nullable Consumer rewind; + + /** + * Whether {@link #rewind} was last told true. The guard on a key that auto-repeats, and the + * thing that lets {@link #releaseAll()} know whether it has anything to let go of. + */ + private boolean rewinding; + public KeyboardInput(final Window gameWindow, final KeyBindings bindings) { this.gameWindow = gameWindow; this.bindings = bindings; @@ -69,9 +91,31 @@ public void setBindings(final KeyBindings bindings) { releaseAll(); } + /** + * Which key runs the game backwards. Read from the config file once at startup, since there is + * no dialog that can change it. + */ + public void setRewindKey(final int keyCode) { + rewindKey = keyCode; + releaseAll(); + } + + /** + * Points rewind at a machine's emulation loop, or at nothing when {@code rewind} is null. Called + * every time a ROM is loaded, since each machine brings its own loop and its own history. + */ + public void setRewind(final @Nullable Consumer rewind) { + releaseAll(); + this.rewind = rewind; + } + /** * Lets go of everything. Wired to the game window losing focus, so that cmd-tabbing away in * the middle of a jump does not leave the button held down for as long as the window is gone. + *

+ * Rewind goes with the buttons, and for a sharper version of the same reason: a held button + * costs a life, where a rewind key stuck down empties the whole history and leaves the game + * sitting half a minute in the past. */ public void releaseAll() { pressed = 0; @@ -79,6 +123,14 @@ public void releaseAll() { if (controller != null) { controller.setButtons(0); } + + if (rewinding) { + rewinding = false; + + if (rewind != null) { + rewind.accept(false); + } + } } @Override @@ -99,7 +151,9 @@ public boolean dispatchKeyEvent(final KeyEvent e) { var button = bindings.buttonFor(e.getKeyCode()); if (button == null) { - return false; + // Asked second, so a key somebody has put a controller button on stays that button. + // Rewind is the emulator's key rather than the game's, and the game wins. + return dispatchRewind(e); } switch (e.getID()) { @@ -126,6 +180,52 @@ public boolean dispatchKeyEvent(final KeyEvent e) { return true; } + /** + * The rewind key, which is held down rather than pressed. + *

+ * Told only on the edges. The key repeats while it is down and the switch on the far side is a + * {@code volatile boolean}, so the repeats would be harmless -- but the flag has to be kept + * anyway for {@link #releaseAll()}, and once it is kept there is nothing to gain from telling + * the emulation thread the same thing thirty times a second. + * + * @return whether the keystroke was rewind's, and so must go no further. + */ + private boolean dispatchRewind(final KeyEvent e) { + var sink = rewind; + + if (sink == null || rewindKey == KeyBindings.UNBOUND || e.getKeyCode() != rewindKey) { + return false; + } + + switch (e.getID()) { + case KeyEvent.KEY_PRESSED -> { + if ((e.getModifiersEx() & SHORTCUT_MODIFIERS) != 0) { + // Cmd-Backspace stays Cmd-Backspace, the same as it would for a button. + return false; + } + + if (!rewinding) { + rewinding = true; + sink.accept(true); + } + } + // Taken whatever else is held down, so reaching for Fast Forward mid-rewind -- which is + // how the game runs backwards at speed -- cannot leave the key stuck on the way out. + case KeyEvent.KEY_RELEASED -> { + if (rewinding) { + rewinding = false; + sink.accept(false); + } + } + // KEY_TYPED carries a character and no key code. + default -> { + return false; + } + } + + return true; + } + /** * Drops both of a pair of opposing directions when both are held. *

diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java index 55d426e..e1ea3a7 100644 --- a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java @@ -306,6 +306,66 @@ void anythingElseLeavesItOff() throws IOException { } } + @Nested + @DisplayName("loading the rewind settings") + class LoadingRewind { + @Test + void aMissingEntryGivesThirtySecondsOnBackspace() throws IOException { + var config = Config.load(write("video.palette=nesdev\n")); + + assertEquals(30, config.rewindSeconds()); + assertEquals(KeyEvent.VK_BACK_SPACE, config.rewindKey()); + } + + @Test + void aNumberNamesItsSeconds() throws IOException { + assertEquals(90, Config.load(write("rewind.seconds=90\n")).rewindSeconds()); + } + + @Test + void surroundingSpaceIsIgnored() throws IOException { + assertEquals(5, Config.load(write("rewind.seconds= 5 \n")).rewindSeconds()); + assertEquals(KeyEvent.VK_HOME, Config.load(write("rewind.key= VK_HOME \n")).rewindKey()); + } + + @Test + void zeroSwitchesTheWholeThingOff() throws IOException { + assertEquals(0, Config.load(write("rewind.seconds=0\n")).rewindSeconds()); + } + + /** + * A wish that can be granted approximately is granted approximately. The ceiling is there + * because an extra nought is a plausible typo and an hour of save states is not something + * to find out about by running out of heap. + */ + @Test + void anImpossibleNumberOfSecondsIsClamped() throws IOException { + assertEquals(300, Config.load(write("rewind.seconds=3000\n")).rewindSeconds()); + assertEquals(0, Config.load(write("rewind.seconds=-5\n")).rewindSeconds()); + } + + /** + * Unlike a number out of range, which says what was wanted. This says nothing, so it lands + * where every other unreadable entry in the file lands. + */ + @Test + void somethingThatIsNotANumberFallsBackToTheDefault() throws IOException { + assertEquals(30, Config.load(write("rewind.seconds=lots\n")).rewindSeconds()); + } + + @Test + void anEmptyKeyLeavesRewindWithNoKeyOnIt() throws IOException { + assertEquals(KeyBindings.UNBOUND, Config.load(write("rewind.key=\n")).rewindKey()); + } + + @Test + void anUnknownKeyNameFallsBackToBackspace() throws IOException { + // VK_BACKSPACE without the underscore is the likely typo, and it is not a constant. + assertEquals(KeyEvent.VK_BACK_SPACE, + Config.load(write("rewind.key=VK_BACKSPACE\n")).rewindKey()); + } + } + @Nested @DisplayName("saving") class Saving { @@ -403,6 +463,30 @@ void theSpriteLimitHackSurvivesTheRoundTrip() throws IOException { assertTrue(Config.load(config()).unlimitedSprites()); } + @Test + void theRewindSettingsSurviveTheRoundTrip() throws IOException { + var config = Config.load(config()); + config.setRewindSeconds(120); + config.setRewindKey(KeyEvent.VK_HOME); + config.save(config()); + + var loaded = Config.load(config()); + + assertEquals(120, loaded.rewindSeconds()); + assertEquals(KeyEvent.VK_HOME, loaded.rewindKey()); + } + + @Test + void aRewindKeyOfNothingReadsBackAsNothing() throws IOException { + // Rather than as the default, which is the trap an empty value falls into if the file + // simply leaves the entry out. + var config = Config.load(config()); + config.setRewindKey(KeyBindings.UNBOUND); + config.save(config()); + + assertEquals(KeyBindings.UNBOUND, Config.load(config()).rewindKey()); + } + @Test void createsTheDirectory() throws IOException { var path = directory.resolve("nested").resolve("config.properties"); @@ -439,6 +523,8 @@ void aSaveWritesEverySection() throws IOException { config.setFastForwardSpeed(EmulationSpeed.TWO_TIMES); config.setMuted(true); config.setUnlimitedSprites(true); + config.setRewindSeconds(45); + config.setRewindKey(KeyEvent.VK_BACK_SPACE); config.save(config()); var text = Files.readString(config()); @@ -451,6 +537,8 @@ void aSaveWritesEverySection() throws IOException { assertTrue(text.contains("emulation.fast-forward=2x"), text); assertTrue(text.contains("audio.muted=true"), text); assertTrue(text.contains("hacks.unlimited-sprites=true"), text); + assertTrue(text.contains("rewind.seconds=45"), text); + assertTrue(text.contains("rewind.key=VK_BACK_SPACE"), text); assertTrue(text.contains("controller1.a=VK_L"), text); } diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java index 32ecdb3..264d968 100644 --- a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java @@ -9,12 +9,14 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.function.LongPredicate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * The emulation thread with a debugger attached to it. @@ -47,6 +49,13 @@ class EmulatorRunnerTests { private static final int STA = 0x8002; private static final int SPIN = 0x8005; + /** + * How much history the rewind tests below give the machine. Small, because the point is to watch + * the frame counter move rather than to hold anything for long -- and because every entry is a + * whole save state. + */ + private static final int REWIND_FRAMES = 120; + private NES nes; private Debugger debugger; private EmulatorRunner runner; @@ -59,7 +68,7 @@ void setUp() { debugger.attach(nes); stops = new ArrayBlockingQueue<>(16); - runner = new EmulatorRunner(nes, new ScreenComponent(), debugger); + runner = new EmulatorRunner(nes, new ScreenComponent(), debugger, REWIND_FRAMES); runner.setStopListener(stops::add); } @@ -175,14 +184,85 @@ void breakingStopsAMachineThatWasRunningFreely() throws Exception { void aMachineWithNothingArmedRunsFrames() { runner.start(); + waitFor(frame -> frame >= 3, "the ordinary path should still run"); + + assertNull(stops.poll(), "and nothing should have stopped it"); + } + + /** + * The frame counter going down is the whole of the feature, and it is the one thing no other + * test can see: {@code RewindTests} proves the ring lands on the right machine, and this proves + * the loop actually asks it to, one frame per tick, for as long as the key is held. + */ + @Test + void holdingRewindRunsTheMachineBackwards() { + runner.start(); + + var played = waitFor(frame -> frame >= 30, "the machine never got going"); + + runner.setRewinding(true); + + // Going back, rather than merely holding still -- which is what a pause would look like + // from out here, and what a rewind that loaded the entry it was already standing on would + // look like too. + var rewound = waitFor(frame -> frame <= played - 10, "the machine never went backwards"); + + runner.setRewinding(false); + + waitFor(frame -> frame > rewound, "and it plays on from wherever the rewind stopped"); + } + + /** + * {@code rewind.seconds=0} builds no ring, and the key then has nothing to do rather than + * something to refuse. + */ + @Test + void aMachineKeepingNoHistoryIgnoresTheRewindKey() { + runner = new EmulatorRunner(nes, new ScreenComponent(), debugger, 0); + runner.start(); + runner.setRewinding(true); + + waitFor(frame -> frame >= 5, "it should have carried on forwards"); + } + + /** + * Pause is looked at first, so a frozen machine stays frozen. Two ideas about what the screen is + * showing is worse than a key that does nothing. + */ + @Test + void aPausedMachineIsNotRewound() throws Exception { + runner.start(); + + var played = waitFor(frame -> frame >= 20, "the machine never got going"); + + runner.setPaused(true); + + // Long enough for the loop to have gone round a few dozen times had it been rewinding. + Thread.sleep(200); + + runner.setRewinding(true); + Thread.sleep(200); + + assertTrue(nes.getPPU().getFrame() >= played, "a paused machine holds where it is"); + } + + /** + * Waits for the frame counter to do something, and says where it got to when it does not. + */ + private long waitFor(final LongPredicate condition, final String message) { var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(PATIENCE_SECONDS); - while (nes.getPPU().getFrame() < 3 && System.nanoTime() < deadline) { + while (System.nanoTime() < deadline) { + var frame = nes.getPPU().getFrame(); + + if (condition.test(frame)) { + return frame; + } + Thread.onSpinWait(); } - assertTrue(nes.getPPU().getFrame() >= 3, "the ordinary path should still run"); - assertNull(stops.poll(), "and nothing should have stopped it"); + return fail(message + ", and it is on frame " + nes.getPPU().getFrame()); } private Debugger.Stop waitForStop() throws InterruptedException { diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/RewindAudioTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/RewindAudioTests.java new file mode 100644 index 0000000..c56a328 --- /dev/null +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/RewindAudioTests.java @@ -0,0 +1,141 @@ +package com.github.dimiro1.mynes.ui; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The sound of the last few seconds, ready to be played backwards. + *

+ * Two properties matter and neither is obvious from the shape of the class. The samples have to come + * back in exactly the reverse of the order they went in, across frame boundaries as well as within + * them -- a ring that reversed each frame but handed the frames over oldest-first would sound like + * the game stuttering rather than rewinding. And a frame has to be taken rather than read, + * because the states it belongs to are discarded as the rewind passes them and sound played twice + * would be sound the picture never showed. + */ +class RewindAudioTests { + + /** + * Somewhere for {@code take} to put what it hands back. Bigger than any number of frames these + * tests ask for, so a short answer is the ring running out rather than the buffer filling up. + */ + private final short[] out = new short[4096]; + + /** + * Frames of easily recognisable sound: frame 1 is 100, 101, 102 and so on, so a sample says both + * which frame it came from and where in it. + */ + private static short[] frame(final int number, final int length) { + var samples = new short[length]; + + for (var i = 0; i < length; i++) { + samples[i] = (short) (number * 100 + i); + } + + return samples; + } + + private static void capture(final RewindAudio audio, final int number, final int length) { + audio.capture(frame(number, length), length); + } + + @Test + void aFrameComesBackBackwards() { + var audio = new RewindAudio(4); + capture(audio, 1, 3); + + assertEquals(3, audio.take(1, out)); + assertArrayEquals(new short[]{102, 101, 100}, first(3)); + } + + /** + * The whole point: two frames asked for at once are one continuous run of sound in reverse, not + * two reversed frames in forward order. + */ + @Test + void severalFramesComeBackAsOneRunOfSoundInReverse() { + var audio = new RewindAudio(4); + capture(audio, 1, 3); + capture(audio, 2, 3); + + assertEquals(6, audio.take(2, out)); + assertArrayEquals(new short[]{202, 201, 200, 102, 101, 100}, first(6)); + } + + @Test + void aFrameIsGoneOnceItHasBeenTaken() { + var audio = new RewindAudio(4); + capture(audio, 1, 2); + capture(audio, 2, 2); + + audio.take(1, out); + + assertEquals(1, audio.size(), "the newest went with the state it belonged to"); + assertEquals(2, audio.take(1, out)); + assertArrayEquals(new short[]{101, 100}, first(2)); + assertEquals(0, audio.take(1, out), "and there is nothing left"); + } + + /** + * A silent frame still takes its place in the ring. Skipping it would put every frame after it + * one out of step with the states, and the rewind would play sound from the wrong second. + */ + @Test + void aSilentFrameStillCountsAsAFrame() { + var audio = new RewindAudio(4); + capture(audio, 1, 2); + audio.capture(new short[0], 0); + capture(audio, 3, 2); + + assertEquals(3, audio.size()); + assertEquals(4, audio.take(3, out), "three frames, four samples between them"); + assertArrayEquals(new short[]{301, 300, 101, 100}, first(4)); + } + + @Test + void theOldestFrameIsDroppedWhenItIsFull() { + var audio = new RewindAudio(2); + capture(audio, 1, 1); + capture(audio, 2, 1); + capture(audio, 3, 1); + + assertEquals(2, audio.size()); + assertEquals(2, audio.take(9, out), "asking for more than it kept is not an error"); + assertArrayEquals(new short[]{300, 200}, first(2)); + } + + /** + * The ring is written round and round, so the interesting case is the one where a frame is taken + * from a slot that has been reused since -- which the arithmetic has to walk backwards through + * without falling off the front of the array. + */ + @Test + void itKeepsWorkingOnceItHasWrappedSeveralTimes() { + var audio = new RewindAudio(3); + + for (var i = 1; i <= 20; i++) { + capture(audio, i, 2); + } + + assertEquals(6, audio.take(3, out)); + assertArrayEquals( + new short[]{2001, 2000, 1901, 1900, 1801, 1800}, + first(6), + "the last three frames, newest first and each one backwards"); + } + + @Test + void aRingNobodyFedHandsBackNothing() { + assertEquals(0, new RewindAudio(4).take(1, out)); + } + + private short[] first(final int count) { + var head = new short[count]; + + System.arraycopy(out, 0, head, 0, count); + + return head; + } +} diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ScreenComponentTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ScreenComponentTests.java index 9695e85..ccc7134 100644 --- a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ScreenComponentTests.java +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ScreenComponentTests.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests for the half of the picture that used to be the PPU's job: turning the colour indices in @@ -29,9 +30,10 @@ class ScreenComponentTests { private static final int OVERSCAN_TOP = FrameRenderer.OVERSCAN_TOP; /** - * Paints the component at 1:1 and reads a pixel back, in framebuffer coordinates. + * Paints the component at 1:1, which is the only way to see what it would put on screen without + * reaching inside it. */ - private static int painted(final ScreenComponent screen, final int x, final int y) { + private static BufferedImage paint(final ScreenComponent screen) { var target = new BufferedImage( PPU.SCREEN_WIDTH, VISIBLE_HEIGHT, BufferedImage.TYPE_INT_RGB); @@ -44,7 +46,35 @@ private static int painted(final ScreenComponent screen, final int x, final int g.dispose(); } - return target.getRGB(x, y - OVERSCAN_TOP) & 0xFFFFFF; + return target; + } + + /** + * Paints the component at 1:1 and reads a pixel back, in framebuffer coordinates. + */ + private static int painted(final ScreenComponent screen, final int x, final int y) { + return paint(screen).getRGB(x, y - OVERSCAN_TOP) & 0xFFFFFF; + } + + /** + * How many pixels of a painted region are not the flat colour the frame was filled with, which + * is how anything drawn over the picture is found without naming where it is. + */ + private static int drawnOver( + final BufferedImage image, + final int left, final int top, final int right, final int bottom, + final int colour) { + var count = 0; + + for (var y = top; y < bottom; y++) { + for (var x = left; x < right; x++) { + if ((image.getRGB(x, y) & 0xFFFFFF) != colour) { + count++; + } + } + } + + return count; } private static int[] frameOf(final int entry) { @@ -134,6 +164,68 @@ void aWholeMultipleLeavesNoLetterbox() { assertEquals(colour, target.getRGB(size.width - 1, size.height - 1) & 0xFFFFFF, "bottom right"); } + /** + * A flat frame paints flat, so anything that is not the fill colour is the marker -- which is + * both halves of what the marker has to be: visible, and only where it is meant to be. + *

+ * The top half is checked separately and has to be untouched. That is where a NES game keeps + * its score and its lives, and a marker sitting on Super Mario Bros.'s timer would be a marker + * in the way. + */ + @Test + void theRewindMarkerIsDrawnOverTheBottomCornerOfThePicture() { + var screen = new ScreenComponent(); + var colour = Palettes.defaultPalette().colour(0x21) & 0xFFFFFF; + + screen.present(frameOf(0x21)); + + assertEquals( + 0, + drawnOver(paint(screen), 0, 0, PPU.SCREEN_WIDTH, VISIBLE_HEIGHT, colour), + "nothing over it yet"); + + screen.setRewinding(true); + + var image = paint(screen); + + assertTrue( + drawnOver(image, 0, VISIBLE_HEIGHT / 2, PPU.SCREEN_WIDTH / 3, VISIBLE_HEIGHT, colour) + > 0, + "the marker belongs in the bottom left"); + assertEquals( + 0, + drawnOver(image, 0, 0, PPU.SCREEN_WIDTH, VISIBLE_HEIGHT / 2, colour), + "and nowhere near the top, where the game keeps its score"); + + screen.setRewinding(false); + + assertEquals( + 0, + drawnOver(paint(screen), 0, 0, PPU.SCREEN_WIDTH, VISIBLE_HEIGHT, colour), + "and it goes away again"); + } + + /** + * Over the picture rather than in it. A marker that reached the framebuffer would turn up in + * screenshots and in the frame hashes the headless mode compares runs with, where it would be a + * lie about what the machine drew. + */ + @Test + void theRewindMarkerStaysOutOfScreenshots() { + var screen = new ScreenComponent(); + var colour = Palettes.defaultPalette().colour(0x21) & 0xFFFFFF; + + screen.present(frameOf(0x21)); + screen.setRewinding(true); + + var snapshot = screen.snapshot(ScreenScale.ONE_TIMES); + + assertEquals( + 0, + drawnOver(snapshot, 0, 0, PPU.SCREEN_WIDTH, VISIBLE_HEIGHT, colour), + "a screenshot is of the machine, and the machine drew none of this"); + } + @Test void aSnapshotIsTheVisiblePictureAtTheSizeAskedFor() { var screen = new ScreenComponent(); diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/debugger/DebuggerFrameTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/debugger/DebuggerFrameTests.java index e06f6e6..f66f1b0 100644 --- a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/debugger/DebuggerFrameTests.java +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/debugger/DebuggerFrameTests.java @@ -40,7 +40,7 @@ static void machine() { debugger.attach(nes); // Never started, so the machine is this thread's throughout and nothing below races. - runner = new EmulatorRunner(nes, new ScreenComponent(), debugger); + runner = new EmulatorRunner(nes, new ScreenComponent(), debugger, 0); } @Test diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java index 374a6b4..b611ba2 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java @@ -5,6 +5,7 @@ import com.github.dimiro1.mynes.cheat.InvalidGameGenieCodeException; import com.github.dimiro1.mynes.debug.Debugger; import com.github.dimiro1.mynes.debug.Disassembler; +import com.github.dimiro1.mynes.state.Rewind; import com.github.dimiro1.mynes.state.SaveStateException; import java.io.BufferedReader; @@ -55,6 +56,9 @@ public final class Repl { genie clear take them all out save-state PATH write the whole machine to a file load-state PATH put one back, from this same ROM + rewind on [FRAMES] start keeping history, 30 seconds of it by default + rewind N go back N frames, or as far as the history goes + rewind off stop keeping it audio peak, RMS and silence since the last audio command help this quit stop @@ -79,6 +83,13 @@ public final class Repl { */ private static final int DEFAULT_DISASM_LINES = 16; + /** + * How much history {@code rewind on} keeps when it is not told, in seconds rather than frames + * because that is the unit the answer is wanted in -- and because the two machines put a + * different number of frames in a second. The window's default is the same thirty. + */ + private static final int DEFAULT_REWIND_SECONDS = 30; + private final Session session; private final Options options; private final BufferedReader in; @@ -173,6 +184,7 @@ private void dispatch(final String[] words) throws IOException { case "genie", "ungenie" -> genie(name, words); case "save-state" -> saveState(words); case "load-state" -> loadState(words); + case "rewind" -> rewind(words); case "audio" -> audio(); case "help" -> reply("help", node -> node.put("commands", HELP)); default -> error(name, "\"" + name + "\" is not a command. Try help."); @@ -651,6 +663,60 @@ private void loadState(final String[] words) { reply("load-state", node -> node.put("path", path.toString())); } + /** + * Starts keeping history, goes back through it, or says how much of it there is. + *

+ * The one command rather than three because the three are one idea, and because the shape reads + * the way it is used: {@code rewind on}, some frames, {@code rewind 30}. Unlike {@code hack} it + * is not a two-position switch -- the interesting form is the middle one, which takes a number. + *

+ * Worth having at all because this is where the feature can be checked. A window is somebody + * holding a key down and a picture that looks about right; here a rewound machine's frame and + * hash come back on the same line, so "it went back to where it was" is an assertion rather than + * an impression. + */ + private void rewind(final String[] words) { + if (words.length < 2) { + reply("rewind", this::putRewind); + return; + } + + switch (words[1].toLowerCase(Locale.ROOT)) { + case "on" -> { + session.armRewind(words.length > 2 + ? (int) number(words[2], "rewind") + : Rewind.framesFor(session.nes().getRegion(), DEFAULT_REWIND_SECONDS)); + + reply("rewind", this::putRewind); + } + case "off" -> { + session.disarmRewind(); + + reply("rewind", this::putRewind); + } + default -> { + // How far it actually went, not how far it was asked to go. A history that ran out + // is the ordinary answer to a key held down, and the difference between the two + // numbers is the whole of what a caller wants to know about it. + var moved = session.rewind((int) number(words[1], "rewind")); + + reply("rewind", node -> { + node.put("framesRewound", moved); + putRewind(node); + }); + } + } + } + + private void putRewind(final Json.Object node) { + node.put("on", session.rewinding()); + + if (session.rewinding()) { + node.put("capacity", session.rewindCapacity()); + node.put("rewindable", session.rewindable()); + } + } + private static long sizeOf(final Path path) { try { return Files.size(path); diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java index 8f9c8fc..c5765cd 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java @@ -163,10 +163,16 @@ public static String write( // Where the run started, which decides whether it is comparable with another one at all. A // run that began from a save state and one that began at power on are not two measurements // of the same thing, and telling them apart is the whole job of this document. + // + // And how much of it was played twice, for the same reason: a session that went back thirty + // frames and ran them again visited those frames with the machine in a state the frame + // counter no longer describes, so its frameChanges and its sound are not the straight run's. + // Always present and 0 when nobody rewound, so two reports still compare key for key. var state = run.putObject("state"); state.put("startedFromPowerOn", options.loadState() == null); put(state, "loadedFrom", options.loadState()); put(state, "savedTo", options.saveState()); + state.put("framesRewound", session.framesRewound()); var cartridge = report.putObject("cart"); cartridge.put("file", cart.filename()); diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Session.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Session.java index 4c3c7f3..b80a51e 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Session.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Session.java @@ -3,9 +3,11 @@ import com.github.dimiro1.mynes.NES; import com.github.dimiro1.mynes.cheat.GameGenie; import com.github.dimiro1.mynes.debug.Debugger; +import com.github.dimiro1.mynes.state.Rewind; import com.github.dimiro1.mynes.state.SaveState; import com.github.dimiro1.mynes.video.FrameAnalysis; import com.github.dimiro1.mynes.video.FrameRenderer; +import org.jetbrains.annotations.Nullable; import javax.imageio.ImageIO; import java.io.IOException; @@ -89,6 +91,23 @@ public record AudioStats(long samples, double peak, double rms, long silentFrame */ private final GameGenie genie = new GameGenie(); + /** + * The last few seconds of the machine, once somebody has asked for them, and null until then. + *

+ * Null at rest rather than an empty ring, the way {@code MMU.genie} is: a one-shot run captures + * nothing and pays one null check a frame for the privilege, which is what keeps a headless run + * exactly as fast as it was before any of this existed. Two to three milliseconds a frame is + * cheap next to a game and ruinous next to a benchmark. + */ + private @Nullable Rewind rewind; + + /** + * How many frames this session has gone back over its whole life, which is what the report + * carries. Cumulative and never reset: the question it answers is whether the run is comparable + * with another one at all, and a run that rewound and played the same frames again is not. + */ + private long framesRewound; + private final short[] samples = new short[AUDIO_BUFFER_SAMPLES]; /** @@ -254,6 +273,13 @@ private Frame endOfFrame(final Debugger.Stop stop) throws IOException { collectAudio(); + // The one place a frame is known to have finished, which is why the ring is fed from here + // rather than from the three loops above: a frame that ends inside stepInstructions is as + // much a frame as one advanceFrame ran, and history that skipped it would count wrong. + if (rewind != null) { + rewind.capture(nes); + } + var hash = FrameAnalysis.hash(ppu.getFrameBuffer()); var changed = hash != previousHash; previousHash = hash; @@ -410,6 +436,99 @@ public void loadState(final Path path) throws IOException { previousHash = FrameAnalysis.hash(nes.getPPU().getFrameBuffer()); } + // ==================================================================================== rewind + + /** + * Starts keeping the last {@code capacityFrames} frames, so the machine can be run backwards + * through them. + *

+ * Captures at once, which is what puts the machine as it stands at the floor of the history + * rather than one frame above it. Explicit rather than always on because a headless run is + * usually a measurement, and a measurement should not quietly cost two milliseconds a frame. + * + * @throws UsageException if it is already armed, since a second call would silently throw the + * history away. + */ + public void armRewind(final int capacityFrames) { + if (rewind != null) { + throw new UsageException( + "rewind is already on, holding " + rewind.capacity() + " frames. Turn it off" + + " first if the point is to start again with a different size."); + } + + try { + rewind = new Rewind(capacityFrames); + } catch (IllegalArgumentException e) { + throw new UsageException(e.getMessage()); + } + + rewind.capture(nes); + } + + /** + * Stops keeping history and drops what there was. Idempotent: turning off something that is + * already off is what somebody meant either way. + */ + public void disarmRewind() { + rewind = null; + } + + /** + * Puts the machine back where it was {@code frames} frames ago. + *

+ * {@link #previousHash} is reseeded here for the same reason {@link #loadState} reseeds it, and + * it is worth saying twice because the two are easy to fix one at a time: it describes a picture + * the machine no longer has, so the next frame would be counted as a change that never happened + * and every {@code frameChanges} in the report would be one out. + * + * @return how many frames it actually moved, which is fewer than asked for when the history ran + * out. + * @throws UsageException if nothing has been kept, since a rewind that answered "0 frames" would + * look the same as one that had simply run out. + */ + public int rewind(final int frames) { + if (rewind == null) { + throw new UsageException( + "rewind is off, so there is no history to go back through. Turn it on with" + + " \"rewind on\" and run some frames first."); + } + + var moved = rewind.rewind(nes, frames); + + framesRewound += moved; + previousHash = FrameAnalysis.hash(nes.getPPU().getFrameBuffer()); + + return moved; + } + + /** + * Whether history is being kept at all. + */ + public boolean rewinding() { + return rewind != null; + } + + /** + * How many frames of history are being kept once it is full, or 0 when it is off. + */ + public int rewindCapacity() { + return rewind == null ? 0 : rewind.capacity(); + } + + /** + * How far back it could go right now, which climbs as a run goes on and stops at the capacity. + */ + public int rewindable() { + return rewind == null ? 0 : rewind.rewindable(); + } + + /** + * How many frames this session has gone back altogether. + */ + public long framesRewound() { + return framesRewound; + } + /** * The bytes of one of the things {@code --dump} can name. * diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java index 30784d3..7bb9e97 100644 --- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java @@ -557,6 +557,28 @@ void theReportSaysWhereTheRunStartedFrom() throws Exception { assertEquals(state.toString(), report().at("/run/state/loadedFrom").asText()); } + /** + * The fourth thing that decides whether two runs are comparable, after the region, the hacks and + * the codes. A run that went back and played the same frames again visited them with a machine + * the frame counter no longer describes, so its sound and its frame changes are not a straight + * run's -- and nothing else in the document would say so. + */ + @Test + void theReportSaysHowMuchOfTheRunWasPlayedTwice() throws Exception { + run(); + + assertEquals(0, report().at("/run/state/framesRewound").asLong(), + "present and zero on a run nobody rewound, so two reports compare key for key"); + + var script = Files.writeString( + out.resolve("session.txt"), "rewind on\nrun 30\nrewind 10\nquit\n"); + + run("--script", script.toString()); + + assertEquals(10, report().at("/run/state/framesRewound").asLong()); + assertEquals(20, report().at("/ppu/frame").asLong(), "thirty run, ten given back"); + } + @Test void aStateFromAnotherCartridgeStopsTheRun() { var state = out.resolve("nestest.mn"); diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java index 9e60f84..b8ba22e 100644 --- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java @@ -378,6 +378,106 @@ void theHelpListsTheStateCommands() throws Exception { assertTrue(help.contains("save-state")); assertTrue(help.contains("load-state")); + assertTrue(help.contains("rewind on")); + assertTrue(help.contains("rewind off")); + } + + // ==================================================================================== rewind + + /** + * The mirror of {@link #aStateGoesBackToWhereItWasTaken}, and the claim is the stronger one: a + * bookmark goes back to a moment somebody chose in advance, where this goes back to a moment + * nobody thought about until it had already passed. + */ + @Test + void rewindGoesBackToWhereTheMachineWas() throws Exception { + var replies = session("rewind on", "run 90", "rewind 30", "quit"); + var straight = session("run 60", "quit"); + + replies.forEach(reply -> assertTrue(reply.get("ok").asBoolean(), reply.toString())); + + assertEquals(90, replies.get(1).get("frame").asLong()); + assertEquals(30, replies.get(2).get("framesRewound").asInt()); + assertEquals(60, replies.get(2).get("frame").asLong()); + assertEquals( + straight.getFirst().get("hash").asText(), + replies.get(2).get("hash").asText(), + "a rewound machine is the machine that never went forward"); + } + + /** + * Thirty seconds is what the window keeps, so the default here is thirty seconds too -- and on + * this machine that is 1803 frames rather than 1800, because a frame is not a sixtieth. + */ + @Test + void rewindKeepsThirtySecondsUnlessToldOtherwise() throws Exception { + var replies = session("rewind on", "run 10", "rewind", "quit"); + + assertTrue(replies.getFirst().get("on").asBoolean()); + assertEquals(1803, replies.getFirst().get("capacity").asInt()); + assertEquals(10, replies.get(2).get("rewindable").asInt(), + "ten frames run and the power-on state under them"); + } + + @Test + void rewindIsClampedToWhatWasKept() throws Exception { + var replies = session("rewind on 10", "run 50", "rewind 99", "quit"); + + assertEquals(10, replies.getFirst().get("capacity").asInt()); + assertEquals(9, replies.get(2).get("framesRewound").asInt(), "nine kept, not ninety-nine"); + assertEquals(41, replies.get(2).get("frame").asLong()); + assertEquals(0, replies.get(2).get("rewindable").asInt(), "parked on the oldest it kept"); + } + + @Test + void rewindReportsItsStatus() throws Exception { + var replies = session("rewind", "rewind on 60", "rewind", "rewind off", "rewind", "quit"); + + assertFalse(replies.getFirst().get("on").asBoolean()); + assertFalse(replies.getFirst().has("capacity"), "nothing to say about a ring that is not there"); + + assertTrue(replies.get(2).get("on").asBoolean()); + assertEquals(60, replies.get(2).get("capacity").asInt()); + + assertFalse(replies.get(4).get("on").asBoolean()); + } + + /** + * Answered rather than fatal, like every other bad command -- and it has to be told apart from a + * history that has simply run out, which also moves no frames. + */ + @Test + void rewindBeforeOnIsAnError() throws Exception { + var replies = session("run 10", "rewind 5", "run 10", "quit"); + + assertFalse(replies.get(1).get("ok").asBoolean()); + assertTrue(replies.get(1).get("error").asText().contains("rewind on"), "and says what to do"); + assertEquals(20, replies.get(2).get("frame").asLong(), "the session carried on regardless"); + } + + @Test + void rewindThatIsMisspeltOrTooSmallToWorkIsAnError() throws Exception { + var replies = session("rewind on 1", "rewind wibble", "rewind on", "rewind on", "quit"); + + assertFalse(replies.getFirst().get("ok").asBoolean(), "one state can never rewind"); + assertFalse(replies.get(1).get("ok").asBoolean()); + assertTrue(replies.get(2).get("ok").asBoolean()); + assertFalse(replies.get(3).get("ok").asBoolean(), "arming twice would drop the history"); + } + + /** + * Turning it off and on again starts the history from here rather than resuming the old one, + * which is the only honest thing it could do with frames nobody was keeping. + */ + @Test + void switchingItOffForgetsTheHistory() throws Exception { + var replies = session( + "rewind on", "run 30", "rewind off", "run 30", "rewind on", "rewind 5", "quit"); + + assertFalse(replies.get(2).get("on").asBoolean()); + assertEquals(0, replies.get(4).get("rewindable").asInt(), "a fresh ring"); + assertEquals(0, replies.get(5).get("framesRewound").asInt()); + assertEquals(60, replies.get(5).get("frame").asLong(), "and nowhere to go from"); } @Test