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