diff --git a/CLAUDE.md b/CLAUDE.md index 6af0783..ad3c238 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,52 @@ The window's ring is not this one: it keeps a state every *other* frame, which h 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. +### Recording a session, and playing it back + +`--record FILE` writes a `.mnm` movie of the run: where it started, one button mask per finished +frame, and a sparse list of the frames Reset was pressed at. `--play FILE` plays one instead of a +schedule. A replay is byte-identical, which is the whole claim and the thing to check after touching +any of it: + +```sh +java -jar $JAR --headless --rom ROM.nes --frames 900 --input 60/40x3:start --reset-at 500 \ + --record take.mnm --save-state a.mn +java -jar $JAR --headless --rom ROM.nes --play take.mnm --save-state b.mn +cmp a.mn b.mn # byte-identical end state +``` + +**A rewind is not in the movie.** Rewinding while recording drops the frames that were taken back, +so a movie holds the timeline that was finally played and a replay never re-enacts the revert. That +composes out of the rewind claim above: a rewound machine is byte for byte the machine that never +went forward, so there is nothing lost by truncating the log to match. + +`--play` is the input, so it refuses `--record`, `--input`, `--input-file`, `--reset-at`, `--genie`, +`--load-state`, `--sram-in` and `--interactive` -- each of those would be a second answer to a +question the movie has already answered. It defaults `--frames` to the movie's own length; asking for +more runs past the end with nothing held down. + +`record`, `record start` and `record stop [PATH]` do the same inside an interactive session -- the +shape of `rewind` rather than of `hack`, since the interesting form is the one that takes a file. +Mutating `genie`/`ungenie`/`genie clear` are refused while recording, because a movie pins the codes +at the moment it starts and a file naming one set that was played against another cannot be replayed. + +**`run.record` and `run.replay` join the comparability checklist**, beside `run.state`, `run.region`, +`run.hacks` and `run.genie`. Both are always present with explicit nulls. A run that started at power +on records a movie that starts there and carries no state at all; anything else -- a `--load-state`, +a `--sram-in`, a loaded state mid-session, or a rewind that went back past the start of the recording +-- puts a save state inside the file, and `run.replay.anchored` is what says so. +`run.state.startedFromPowerOn` is false for a replay of one of those. + +The Game Genie codes ride inside the movie and are put back on replay. They have to: a cheated +cartridge is byte for byte an honest one, so `cart.sha256` cannot tell them apart and nothing else in +the file would. + +The desktop has **Machine > Record Movie... / Play Movie...**. While either is running the pad is +latched once a frame on the emulation thread rather than reaching the controller the moment a key +moves, and Power Cycle, Region and the Game Genie item are greyed out -- the first two would build a +new machine and take the recorder with it. Rewinding during playback stops it and hands the game +back. + ### Running a romhack `--patch FILE` applies an IPS patch to the ROM before anything reads it as a cartridge. Repeatable, @@ -313,7 +359,7 @@ Four Maven modules, and the arrows between them only point one way. mynes-core/ depends on nothing mynes/ the console: CPU, PPU, APU, BUS, MMU, VRAM, Cart, Region, controllers mynes/mappers/ mappers 0 to 4 - mynes/state/ save states and battery .sav files + mynes/state/ save states, battery .sav files, and .mnm session recordings mynes/debug/ the disassembler and the breakpoints, shared by the window and the REPL mynes/cheat/ Game Genie codes, and the device MMU asks on every read of PRG ROM mynes/video/ colour indices to pixels: the overscan crop and the frame renderer diff --git a/README.md b/README.md index 480be37..084a3ef 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,13 @@ and there is no patched copy of it to keep anywhere. A patched game keeps its ow battery file, named after the patch rather than the ROM, so an afternoon with a hack cannot write over fifty hours of the original. `--patch` does the same thing from the command line. +**Session recordings**, from **Machine > Record Movie...** and **Machine > Play Movie...**. A movie +is not a video: it is where the run started, one button mask per frame, and the frames Reset was +pressed at, which comes to a few hundred bytes a minute. Playing one back reproduces the session byte +for byte, because nothing in the machine reads a clock or a random number. Rewinding while recording +drops the frames you took back, so a movie holds the run you finally played rather than the one you +undid. `--record` and `--play` do the same from the command line. + **Save states and battery saves**, and a **headless mode** for running with no window at all. Both have a section of their own below. @@ -259,6 +266,24 @@ The difference is worth keeping in mind. A save state is a bookmark, and losing minutes. A `.sav` is fifty hours of Zelda, which is why that one is written to a temporary file and moved into place, so that a crash halfway through cannot take both it and its replacement. +**Movies** are the third file here and the odd one out, because they hold no picture of the machine +at all. A `.mnm` is where the run started, one byte per frame saying what was held down, and a list +of the frames Reset was pressed at -- ninety seconds of play is about five kilobytes before it is +compressed. It can be that small only because the console is deterministic: the same cartridge given +the same buttons on the same frames arrives at the same bytes, so playing one back reproduces the +session exactly rather than approximately. + +Recording from power on carries no state at all, which makes a movie of a whole playthrough something +you can hand to anybody with the same ROM. Starting one part way through puts a save state inside the +file to begin from, since there is otherwise nothing to say where the beginning was. Game Genie codes +travel inside the movie and are put back on playback -- they have to, because a cheated cartridge is +byte for byte an honest one and nothing else in the file could say so. + +Rewinding while recording drops the frames you took back rather than appending them, so a movie is +the timeline you finally played and a replay never re-enacts the revert. Power Cycle and Region are +greyed out while a movie is running, since both build a new machine and the recording would go with +it. + ## Headless mode The emulator also runs with nobody watching: no window, no sound card. That is useful from a script, @@ -318,6 +343,12 @@ seconds to start up, the jar about a third of one. so `cart.sha256` is the plain one and `run.genie` is the only thing in the report that tells a cheated run from an honest one. Which also means a save state taken with codes in will load into a machine with them out, without a word of complaint. +- **`--record FILE` and `--play FILE`** write and replay a session. `--play` is the input, so it + refuses `--input`, `--reset-at`, `--genie`, `--load-state` and the rest of what a movie already + says, and it defaults `--frames` to the movie's own length -- ask for more and the run carries on + past the end with nothing held down. `run.record` and `run.replay` in the report join the list of + things to check before diffing two runs. `record start` and `record stop` are commands in the + interactive session as well. - **`--interactive`** reads commands on standard input and answers each with a line of JSON, for when you do not yet know the question well enough to write it down. It is also where the debugger lives without a window: `break`, `watch`, `step` and `disasm`, with `run` reporting back what diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java new file mode 100644 index 0000000..bdd8abf --- /dev/null +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java @@ -0,0 +1,707 @@ +package com.github.dimiro1.mynes.state; + +import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Region; +import com.github.dimiro1.mynes.cheat.GameGenieCode; +import com.github.dimiro1.mynes.cheat.InvalidGameGenieCodeException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipException; + +/** + * A session somebody played, small enough to hand to anybody with the same cartridge. + * + *
+ * Those outside facts are the ones worth naming, because getting any of them wrong is a replay that + * quietly diverges rather than one that refuses: the ROM's digest (of the patched image, so + * a romhack is pinned for free), the region, and the Game Genie codes -- which are the sharp case, + * since a cheated cartridge is byte for byte the honest one and nothing else in a file would say so. + * + *
+ * The anchor is a whole {@link SaveState} file nested inside the body rather than unpacked into it. + * That double-gzips a few kilobytes, and buys the thing worth having: there is exactly one tested + * way of putting a machine back, and a movie uses it. + *
+ * There is no player class. The accessors below are pure decision functions in the shape of the
+ * headless {@code InputSchedule}, and the cursor lives in whichever loop is driving -- which is what
+ * lets the same file be played by a one-shot run, by a REPL and by the window's emulation thread
+ * without any of them sharing a mutable object.
+ */
+public final class Movie {
+
+ /**
+ * "MYNESMV" and the same trailing SUB an iNES header carries, which stops {@code cat} on a
+ * terminal before it has finished redecorating it.
+ */
+ private static final byte[] MAGIC = {'M', 'Y', 'N', 'E', 'S', 'M', 'V', 0x1A};
+
+ /**
+ * Bumped when something already in the format changes meaning -- not when something is added,
+ * which the chunk lengths already carry.
+ */
+ public static final int VERSION = 1;
+
+ /**
+ * Everything before the body. Fixed, so the body can be found without reading the header.
+ */
+ static final int HEADER_BYTES = 68;
+
+ private static final int OFFSET_VERSION = 8;
+ private static final int OFFSET_SHA256 = 10;
+ private static final int OFFSET_MAPPER = 42;
+ private static final int OFFSET_FLAGS = 43;
+ private static final int OFFSET_ANCHOR_FRAME = 44;
+ private static final int OFFSET_FRAME_COUNT = 52;
+ private static final int OFFSET_PORTS = 60;
+ private static final int OFFSET_BODY_LENGTH = 64;
+
+ private static final int SHA256_BYTES = 32;
+
+ /**
+ * Bit 0 of the flags byte: the body is gzipped.
+ */
+ private static final int FLAG_GZIPPED = 0x01;
+
+ /**
+ * Bit 1: the machine was a PAL one, which is the {@link SaveState} bit and means the same thing.
+ */
+ private static final int FLAG_PAL = 0x02;
+
+ /**
+ * Bit 2: there is a save state in the body and the movie starts from it rather than from power
+ * on. Every other bit is reserved and written zero.
+ */
+ private static final int FLAG_ANCHORED = 0x04;
+
+ /**
+ * A complete {@link SaveState} file, byte for byte. Present exactly when {@link #FLAG_ANCHORED}
+ * is set.
+ */
+ private static final String TAG_ANCHOR = "ANCH";
+
+ /**
+ * One button mask per frame, raw. Mask i is the mask in force from the anchor's frame
+ * plus i to the frame after it. Raw rather than run-length encoded because the gzip
+ * crushes the runs and a second encoding is a second thing to get wrong.
+ */
+ private static final String TAG_CONTROLLER1 = "CTL1";
+
+ /**
+ * The same shape for player two, which nothing wires up today. Never written by this version and
+ * applied by a reader only when the header says there are two ports, so a movie recorded by a
+ * later build that does wire it will still play its first player here.
+ */
+ private static final String TAG_CONTROLLER2 = "CTL2";
+
+ /**
+ * Frame indices, each a u64, strictly increasing: the Reset button was pressed at the start of
+ * that frame. Sparse because resets are rare, and absent altogether when there were none.
+ */
+ private static final String TAG_RESETS = "RSET";
+
+ /**
+ * The Game Genie codes that were in when the recording started, each a length byte and its ASCII
+ * letters. The one thing in a movie that a cartridge digest could never stand in for.
+ */
+ private static final String TAG_GENIE = "GENI";
+
+ private static final int TAG_BYTES = 4;
+
+ /**
+ * How many controller lanes a movie written by this build carries.
+ */
+ public static final int PORTS = 1;
+
+ /**
+ * How long a chunk this will inflate before deciding the file is lying to it. Generous -- a
+ * movie of a whole evening is a few megabytes of masks, and the anchor is a state -- and there
+ * only so that a corrupt length cannot ask for an array the size of the heap.
+ */
+ private static final int MAX_CHUNK_BYTES = 256 * 1024 * 1024;
+
+ private final Header header;
+
+ /**
+ * The whole save state the movie starts from, or null when it starts at power on.
+ */
+ private final byte[] anchor;
+
+ private final byte[] player1;
+
+ /**
+ * Null unless a later build wrote a second lane and this file has one.
+ */
+ private final byte[] player2;
+
+ private final long[] resets;
+
+ private final List
+ * Zero past the end, and past either end. Running a replay longer than the
+ * movie is a legitimate thing to want -- watch what the game does when the player stops playing
+ * -- and the honest answer for a frame nobody recorded is that nobody was touching the pad.
+ */
+ public int buttonsAt(final long index) {
+ return index >= 0 && index < player1.length ? Byte.toUnsignedInt(player1[(int) index]) : 0;
+ }
+
+ /**
+ * The same for player two, which is always 0 for a movie this build recorded.
+ */
+ public int buttons2At(final long index) {
+ return player2 != null && index >= 0 && index < player2.length
+ ? Byte.toUnsignedInt(player2[(int) index])
+ : 0;
+ }
+
+ /**
+ * Whether the Reset button was pressed at the start of the {@code index}th frame.
+ */
+ public boolean resetsAt(final long index) {
+ return Arrays.binarySearch(resets, index) >= 0;
+ }
+
+ /**
+ * The frames Reset was pressed at the start of, as movie-relative indices.
+ */
+ public long[] resets() {
+ return resets.clone();
+ }
+
+ /**
+ * The Game Genie codes that were in the cartridge slot, decoded when the file was read. A replay
+ * has to put these back, since the cartridge is untouched by them and nothing else in the file
+ * would tell a cheated recording from an honest one.
+ */
+ public List
+ * Everything that could be wrong is checked before the machine is touched, exactly as
+ * {@link SaveState#read} does it and for the same reason: an anchor applied halfway would leave
+ * a console that is half one game and half another.
+ *
+ * @throws MovieException if it was recorded on another cartridge or another machine, or if it
+ * starts at power on and this machine has already run.
+ */
+ public void applyAnchor(final NES nes) {
+ var cart = nes.getCart();
+
+ if (!header.romSHA256().equals(cart.sha256())) {
+ throw new MovieException(
+ "that movie was recorded on another cartridge. It belongs to mapper "
+ + header.mapperNumber() + " " + header.romSHA256().substring(0, 12)
+ + ", and the one in the machine is mapper " + cart.mapperNumber() + " "
+ + cart.sha256().substring(0, 12) + ".");
+ }
+
+ if (header.region() != nes.getRegion()) {
+ throw new MovieException(
+ "that movie was recorded on a " + header.region().label()
+ + " machine and this one is " + nes.getRegion().label()
+ + ". The cartridge is right, but a frame is not the same length on the"
+ + " two, so playing the buttons back would drift apart immediately.");
+ }
+
+ if (anchor == null) {
+ if (nes.getPPU().getFrame() != 0) {
+ throw new MovieException(
+ "that movie starts at power on and this machine is already at frame "
+ + nes.getPPU().getFrame() + ". Start it again from the beginning.");
+ }
+
+ return;
+ }
+
+ try {
+ SaveState.read(nes, new ByteArrayInputStream(anchor));
+ } catch (IOException e) {
+ throw new AssertionError("a state read from memory cannot fail", e);
+ }
+ }
+
+ // ==================================================================================== writing
+
+ /**
+ * Writes the movie out.
+ */
+ public void write(final OutputStream out) throws IOException {
+ var body = body();
+ var bytes = new byte[HEADER_BYTES];
+
+ System.arraycopy(MAGIC, 0, bytes, 0, MAGIC.length);
+ SaveState.putShort(bytes, OFFSET_VERSION, VERSION);
+ System.arraycopy(
+ SaveState.hexToBytes(header.romSHA256()), 0, bytes, OFFSET_SHA256, SHA256_BYTES);
+ bytes[OFFSET_MAPPER] = (byte) header.mapperNumber();
+ bytes[OFFSET_FLAGS] = (byte) (FLAG_GZIPPED
+ | (header.region() == Region.PAL ? FLAG_PAL : 0)
+ | (anchor != null ? FLAG_ANCHORED : 0));
+ SaveState.putLong(bytes, OFFSET_ANCHOR_FRAME, header.anchorFrame());
+ SaveState.putLong(bytes, OFFSET_FRAME_COUNT, header.frameCount());
+ bytes[OFFSET_PORTS] = (byte) header.ports();
+ SaveState.putInt(bytes, OFFSET_BODY_LENGTH, body.length);
+
+ out.write(bytes);
+
+ // Finished rather than closed, so a caller's try-with-resources still owns the stream.
+ var gzip = new GZIPOutputStream(out);
+ gzip.write(body);
+ gzip.finish();
+ }
+
+ /**
+ * Writes the movie to a file, and does not destroy what was there until it has worked.
+ *
+ * Through a temporary and a move, for the reason {@link SaveState#write(NES, Path)} is: a take
+ * somebody has just played is worth more than a partially overwritten file.
+ */
+ public void write(final Path path) throws IOException {
+ var temporary = path.resolveSibling(path.getFileName() + ".tmp");
+ var parent = path.getParent();
+
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+
+ try (var out = Files.newOutputStream(temporary)) {
+ write(out);
+ }
+
+ Files.move(temporary, path,
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ private byte[] body() throws IOException {
+ var body = new ByteArrayOutputStream();
+
+ if (anchor != null) {
+ chunk(body, TAG_ANCHOR, anchor);
+ }
+
+ chunk(body, TAG_CONTROLLER1, player1);
+
+ if (resets.length > 0) {
+ var bytes = new byte[resets.length * 8];
+
+ for (var i = 0; i < resets.length; i++) {
+ SaveState.putLong(bytes, i * 8, resets[i]);
+ }
+
+ chunk(body, TAG_RESETS, bytes);
+ }
+
+ if (!genie.isEmpty()) {
+ var codes = new ByteArrayOutputStream();
+
+ for (var code : genie) {
+ var letters = code.text().getBytes(StandardCharsets.US_ASCII);
+
+ codes.write(letters.length);
+ codes.write(letters);
+ }
+
+ chunk(body, TAG_GENIE, codes.toByteArray());
+ }
+
+ return body.toByteArray();
+ }
+
+ private static void chunk(
+ final ByteArrayOutputStream body, final String tag, final byte[] payload)
+ throws IOException {
+ body.write(tag.getBytes(StandardCharsets.US_ASCII));
+
+ var length = new byte[4];
+ SaveState.putInt(length, 0, payload.length);
+
+ body.write(length);
+ body.write(payload);
+ }
+
+ // ==================================================================================== reading
+
+ /**
+ * Reads a movie, checking everything about it.
+ *
+ * Nothing here needs a machine, and that is deliberate: a front end can refuse a file, name what
+ * is wrong with it and carry on playing whatever it was playing. {@link #applyAnchor} is where a
+ * machine first comes into it.
+ *
+ * @throws MovieException if it is not a movie, is from a later version, or is damaged in any of
+ * the ways the structure can be damaged.
+ */
+ public static Movie read(final InputStream in) throws IOException {
+ var file = in.readAllBytes();
+
+ if (file.length < HEADER_BYTES) {
+ throw new MovieException("that file is too short to be a movie.");
+ }
+
+ if (!Arrays.equals(file, 0, MAGIC.length, MAGIC, 0, MAGIC.length)) {
+ throw new MovieException("that is not a movie.");
+ }
+
+ var header = header(file);
+
+ if (header.formatVersion() > VERSION) {
+ throw new MovieException(
+ "that movie is version " + header.formatVersion()
+ + " and this build only understands up to " + VERSION + ".");
+ }
+
+ var declared = SaveState.readInt(file, OFFSET_BODY_LENGTH);
+ var body = inflate(file, (file[OFFSET_FLAGS] & FLAG_GZIPPED) != 0);
+
+ if (body.length != declared) {
+ throw new MovieException(
+ "that movie is damaged: it says " + declared + " bytes and holds "
+ + body.length + ".");
+ }
+
+ var chunks = chunks(body);
+ var player1 = chunks.get(TAG_CONTROLLER1);
+
+ if (player1 == null) {
+ throw new MovieException(
+ "that movie has no \"CTL1\" in it, so there are no buttons to play back.");
+ }
+
+ if (header.frameCount() != player1.length) {
+ throw new MovieException(
+ "that movie is damaged: it says " + header.frameCount() + " frames and holds "
+ + player1.length + " button masks.");
+ }
+
+ var anchor = chunks.get(TAG_ANCHOR);
+
+ if (header.anchored() != (anchor != null)) {
+ throw new MovieException(header.anchored()
+ ? "that movie says it starts from a save state and has none in it."
+ : "that movie says it starts at power on and has a save state in it.");
+ }
+
+ if (anchor != null) {
+ // Checked here rather than left to SaveState.read, so that a movie whose anchor came
+ // from somewhere else is refused before any machine is touched -- and refused as a
+ // damaged movie, which is what it is, rather than as an unloadable state.
+ var inside = SaveState.headerOf(anchor);
+
+ if (!inside.romSHA256().equals(header.romSHA256())) {
+ throw new MovieException(
+ "that movie is damaged: the save state it starts from was taken from"
+ + " another cartridge.");
+ }
+
+ if (inside.region() != header.region()) {
+ throw new MovieException(
+ "that movie is damaged: the save state it starts from was taken from a "
+ + inside.region().label() + " machine and the movie says "
+ + header.region().label() + ".");
+ }
+ }
+
+ return new Movie(
+ header,
+ anchor,
+ player1,
+ header.ports() >= 2 ? chunks.get(TAG_CONTROLLER2) : null,
+ resets(chunks.get(TAG_RESETS), header.frameCount()),
+ codes(chunks.get(TAG_GENIE)));
+ }
+
+ /**
+ * Reads a movie from a file.
+ */
+ public static Movie read(final Path path) throws IOException {
+ try (var in = Files.newInputStream(path)) {
+ return read(in);
+ }
+ }
+
+ /**
+ * What the file says about itself. Reads the header alone, which is why it is not compressed --
+ * so a chooser can say "1,203 frames, from a state at frame 4,201" without inflating anything,
+ * and a movie from the wrong cartridge can be refused before it is opened properly.
+ */
+ public static Header header(final Path path) throws IOException {
+ var header = new byte[HEADER_BYTES];
+
+ try (var in = Files.newInputStream(path)) {
+ if (in.readNBytes(header, 0, HEADER_BYTES) < HEADER_BYTES) {
+ throw new MovieException("that file is too short to be a movie.");
+ }
+ }
+
+ if (!Arrays.equals(header, 0, MAGIC.length, MAGIC, 0, MAGIC.length)) {
+ throw new MovieException("that is not a movie.");
+ }
+
+ return header(header);
+ }
+
+ private static Header header(final byte[] file) {
+ var sha256 = new byte[SHA256_BYTES];
+ System.arraycopy(file, OFFSET_SHA256, sha256, 0, SHA256_BYTES);
+
+ var frameCount = SaveState.readLong(file, OFFSET_FRAME_COUNT);
+
+ if (frameCount < 0 || frameCount > Integer.MAX_VALUE) {
+ throw new MovieException(
+ "that movie says it holds " + frameCount + " frames, which is not a number of"
+ + " frames anybody played.");
+ }
+
+ return new Header(
+ SaveState.readShort(file, OFFSET_VERSION),
+ SaveState.bytesToHex(sha256),
+ Byte.toUnsignedInt(file[OFFSET_MAPPER]),
+ (file[OFFSET_FLAGS] & FLAG_PAL) != 0 ? Region.PAL : Region.NTSC,
+ (file[OFFSET_FLAGS] & FLAG_ANCHORED) != 0,
+ SaveState.readLong(file, OFFSET_ANCHOR_FRAME),
+ frameCount,
+ Byte.toUnsignedInt(file[OFFSET_PORTS]));
+ }
+
+ private static byte[] inflate(final byte[] file, final boolean gzipped) throws IOException {
+ var body = Arrays.copyOfRange(file, HEADER_BYTES, file.length);
+
+ if (!gzipped) {
+ return body;
+ }
+
+ // readAllBytes checks the trailing CRC32, so a damaged file is caught here rather than
+ // showing up later as a replay that mysteriously diverges.
+ try (var gzip = new GZIPInputStream(new ByteArrayInputStream(body))) {
+ return gzip.readAllBytes();
+ } catch (EOFException | ZipException e) {
+ throw new MovieException("that movie is damaged.", e);
+ }
+ }
+
+ private static Map
+ * The mirror of {@link SaveStateException}, and separate from it for the reason the two files are
+ * separate: a state that will not load and a movie that will not play are different mistakes, and a
+ * front end that wanted to answer only one of them could not tell them apart otherwise. The message
+ * is shown on its own -- in a dialog, or on stderr with no stack trace in front of it -- so it has
+ * to read as a sentence somebody can act on.
+ */
+public class MovieException extends RuntimeException {
+ public MovieException(final String message) {
+ super(message);
+ }
+
+ public MovieException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java
new file mode 100644
index 0000000..bd1d5d7
--- /dev/null
+++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java
@@ -0,0 +1,266 @@
+package com.github.dimiro1.mynes.state;
+
+import com.github.dimiro1.mynes.NES;
+import com.github.dimiro1.mynes.Region;
+import com.github.dimiro1.mynes.cheat.GameGenieCode;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * The log a {@link Movie} is made of, while it is still growing.
+ *
+ * Driver-owned like {@link Rewind}, and deliberately not reachable from {@link NES}
+ * -- {@code SaveStateCompletenessTests} walks everything the console can reach and scrambles every
+ * array it finds, so a growing log hanging off a chip would be walked into and shredded. This
+ * belongs to whoever is driving the machine, which is also the honest place for it: a machine does
+ * not know that anybody is writing down what it does.
+ *
+ *
+ * The smaller and the more portable of the two: the file is buttons and nothing else, and
+ * anybody with the same cartridge can play it. It is only honest when the machine really is
+ * untouched, which is what the check below is for -- and note that "untouched" includes the
+ * cartridge's battery RAM, which a movie has no way to carry, so a session that filled it from a
+ * {@code .sav} has to anchor instead.
+ *
+ * @throws MovieException if the machine has already run.
+ */
+ public static MovieRecorder atPowerOn(final NES nes, final List
+ * What the window always does, because a player who has just decided to record something is
+ * hardly ever sitting on a machine that has not run yet.
+ */
+ public static MovieRecorder anchoredAt(final NES nes, final List
+ * To be called at the end of every finished frame and nowhere else, which is the same contract
+ * {@link Rewind#capture} has and for a sharper reason: this counts in frames, so a call from
+ * anywhere but a frame boundary puts every later index out by one and the replay diverges from
+ * that point on.
+ */
+ public void frame(final int mask) {
+ if (recorded == buttons.length) {
+ buttons = Arrays.copyOf(buttons, buttons.length * 2);
+ }
+
+ buttons[recorded++] = (byte) mask;
+ }
+
+ /**
+ * The console's Reset button, pressed at the start of the frame that is about to run.
+ *
+ * Called before the machine is told, so that the index written down is the frame the reset will
+ * be seen in rather than the one before it. Pressing it twice before a frame runs is one press:
+ * the console has one button and a replay applies it once.
+ */
+ public void reset() {
+ if (resetCount > 0 && resets[resetCount - 1] == recorded) {
+ return;
+ }
+
+ if (resetCount == resets.length) {
+ resets = Arrays.copyOf(resets, Math.max(8, resets.length * 2));
+ }
+
+ resets[resetCount++] = recorded;
+ }
+
+ /**
+ * The machine has gone back {@code frames} frames.
+ *
+ * Frames, not rewind steps. {@link Rewind#rewind} answers in states, and a
+ * state is {@link Rewind#interval()} frames -- so the window, which keeps one every other frame,
+ * has to hand over the difference in the PPU's own frame counter rather than what that call
+ * returned.
+ */
+ public void rewound(final NES nes, final long frames) {
+ if (frames <= 0) {
+ return;
+ }
+
+ if (frames > recorded) {
+ // Back past the start of the recording, so there is no longer a log that describes where
+ // the machine is. Starting again from here is the only honest answer, and it keeps the
+ // take alive rather than throwing away everything somebody is about to play next.
+ reanchor(nes);
+ return;
+ }
+
+ recorded -= (int) frames;
+
+ while (resetCount > 0 && resets[resetCount - 1] >= recorded) {
+ resetCount--;
+ }
+ }
+
+ /**
+ * The machine has been replaced wholesale -- a loaded save state, and nothing else.
+ *
+ * Not a rewind, which has its own method and its own answer: this is a machine nobody played
+ * their way to, so there is no timeline to truncate.
+ */
+ public void jumped(final NES nes) {
+ reanchor(nes);
+ }
+
+ /**
+ * How many frames are in the log, which is where the next one will go.
+ */
+ public long framesRecorded() {
+ return recorded;
+ }
+
+ /**
+ * Whether the recording carries a state to start from, rather than starting at power on. Can
+ * become true part way through a take, since a rewind past the start or a loaded state
+ * re-anchors it.
+ */
+ public boolean anchored() {
+ return anchor != null;
+ }
+
+ /**
+ * The frame the recording currently starts on.
+ */
+ public long anchorFrame() {
+ return anchorFrame;
+ }
+
+ /**
+ * The movie as it stands. Cheap enough to ask for repeatedly: it copies the log rather than
+ * freezing it, so recording can carry on afterwards.
+ */
+ public Movie movie() {
+ return new Movie(
+ new Movie.Header(
+ Movie.VERSION,
+ romSHA256,
+ mapperNumber,
+ region,
+ anchor != null,
+ anchorFrame,
+ recorded,
+ Movie.PORTS),
+ anchor,
+ Arrays.copyOf(buttons, recorded),
+ null,
+ Arrays.copyOf(resets, resetCount),
+ genie);
+ }
+
+ private void reanchor(final NES nes) {
+ anchor = capture(nes);
+ anchorFrame = nes.getPPU().getFrame();
+ recorded = 0;
+ resetCount = 0;
+ }
+
+ private static byte[] capture(final NES nes) {
+ 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);
+ }
+
+ return out.toByteArray();
+ }
+}
diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/SaveState.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/SaveState.java
index c5c7c7e..3d6c99d 100644
--- a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/SaveState.java
+++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/SaveState.java
@@ -224,16 +224,7 @@ private static void chunk(
*/
public static void read(final NES nes, final InputStream in) throws IOException {
var file = in.readAllBytes();
-
- if (file.length < HEADER_BYTES) {
- throw new SaveStateException("that file is too short to be a save state.");
- }
-
- if (!Arrays.equals(file, 0, MAGIC.length, MAGIC, 0, MAGIC.length)) {
- throw new SaveStateException("that is not a save state.");
- }
-
- var header = header(file);
+ var header = headerOf(file);
if (header.formatVersion() > VERSION) {
throw new SaveStateException(
@@ -336,6 +327,24 @@ public static Header header(final Path path) throws IOException {
return header(header);
}
+ /**
+ * The same, for a state held in memory rather than in a file.
+ *
+ * Package-private because a {@link Movie}'s anchor is a whole state file nested inside it, and a
+ * movie validates everything it holds before any machine exists to be touched.
+ */
+ static Header headerOf(final byte[] file) {
+ if (file.length < HEADER_BYTES) {
+ throw new SaveStateException("that file is too short to be a save state.");
+ }
+
+ if (!Arrays.equals(file, 0, MAGIC.length, MAGIC, 0, MAGIC.length)) {
+ throw new SaveStateException("that is not a save state.");
+ }
+
+ return header(file);
+ }
+
private static Header header(final byte[] file) {
var sha256 = new byte[SHA256_BYTES];
System.arraycopy(file, OFFSET_SHA256, sha256, 0, SHA256_BYTES);
@@ -403,29 +412,33 @@ private static void apply(
}
// ================================================================================= plain bytes
+ //
+ // Package-private rather than private because Movie writes a header of the same shape -- big
+ // endian, fixed width, outside the compression -- and two copies of "put a long at an offset"
+ // is two places for the endianness to be got wrong.
- private static void putShort(final byte[] target, final int offset, final int value) {
+ static void putShort(final byte[] target, final int offset, final int value) {
target[offset] = (byte) (value >> 8);
target[offset + 1] = (byte) value;
}
- private static void putInt(final byte[] target, final int offset, final int value) {
+ static void putInt(final byte[] target, final int offset, final int value) {
for (var i = 0; i < 4; i++) {
target[offset + i] = (byte) (value >> (24 - i * 8));
}
}
- private static void putLong(final byte[] target, final int offset, final long value) {
+ static void putLong(final byte[] target, final int offset, final long value) {
for (var i = 0; i < 8; i++) {
target[offset + i] = (byte) (value >> (56 - i * 8));
}
}
- private static int readShort(final byte[] source, final int offset) {
+ static int readShort(final byte[] source, final int offset) {
return Byte.toUnsignedInt(source[offset]) << 8 | Byte.toUnsignedInt(source[offset + 1]);
}
- private static int readInt(final byte[] source, final int offset) {
+ static int readInt(final byte[] source, final int offset) {
var value = 0;
for (var i = 0; i < 4; i++) {
@@ -435,7 +448,7 @@ private static int readInt(final byte[] source, final int offset) {
return value;
}
- private static long readLong(final byte[] source, final int offset) {
+ static long readLong(final byte[] source, final int offset) {
var value = 0L;
for (var i = 0; i < 8; i++) {
@@ -445,7 +458,7 @@ private static long readLong(final byte[] source, final int offset) {
return value;
}
- private static String bytesToHex(final byte[] bytes) {
+ static String bytesToHex(final byte[] bytes) {
var hex = new StringBuilder(bytes.length * 2);
for (var b : bytes) {
@@ -455,7 +468,7 @@ private static String bytesToHex(final byte[] bytes) {
return hex.toString();
}
- private static byte[] hexToBytes(final String hex) {
+ static byte[] hexToBytes(final String hex) {
var bytes = new byte[hex.length() / 2];
for (var i = 0; i < bytes.length; i++) {
diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/package-info.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/package-info.java
index 3d31ac1..d864057 100644
--- a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/package-info.java
+++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/package-info.java
@@ -1,7 +1,7 @@
/**
* Freezing the machine to a file, and thawing it again.
*
- * Two different files live here, and they are not variations on one idea.
+ * Three different files live here, and they are not variations on one idea.
*
* A save state ({@link com.github.dimiro1.mynes.state.SaveState}) is every field
* in the console, this emulator's own format, loadable only by builds that still understand it.
@@ -13,6 +13,12 @@
* and write. That interoperability is the entire specification, so nothing may be added to it --
* not a magic number, not a checksum, however tempting.
*
+ * A movie ({@link com.github.dimiro1.mynes.state.Movie}) is neither: it is a
+ * session somebody played, kept as one button mask per frame rather than as any picture of the
+ * machine. It can be that small only because the console is deterministic, which is a property of
+ * the emulator rather than of the format -- so unlike the two above, a movie is a claim about how
+ * this build behaves, and {@code MovieTests} is where the claim is checked.
+ *
* The distinction is worth keeping in mind when something goes wrong with either. A save state is
* a convenience, and losing one costs somebody a few minutes. A battery file is the player's
* progress through the game, which is why it is the one written through a temporary and a move.
diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java
new file mode 100644
index 0000000..5133350
--- /dev/null
+++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java
@@ -0,0 +1,638 @@
+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.cheat.GameGenieCode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Recording a session, and playing it back.
+ *
+ * The claim is the strong one, and every test that can make it does: a replayed machine is not
+ * approximately the machine that was recorded, it is that machine, byte for byte. So the comparisons
+ * below are of {@link SaveState} bytes rather than of pictures -- the picture stops being evidence
+ * as soon as a ROM settles down, and the state carries the cycle counters, the interrupt latches and
+ * every APU channel nobody can hear.
+ *
+ * The other half of what is here is the container, in the shape {@link SaveStateFormatTests} uses:
+ * everything a file can be wrong about is found out before any machine is touched.
+ */
+class MovieTests {
+
+ /**
+ * Where these runs press Start, and how long for. The same frames {@link RewindTests} uses, and
+ * for the same reason: nestest is known to notice a button and redraw over them, which is what
+ * makes a second timeline genuinely a second timeline rather than the same one twice.
+ */
+ private static final int PRESS_AT = 60;
+ private static final int PRESS_FOR = 30;
+
+ /**
+ * Enough history for every rewind here, so a ring that evicts is something a test asks for
+ * rather than something it stumbles into.
+ */
+ private static final int ROOMY = 400;
+
+ @TempDir
+ private Path directory;
+
+ // ================================================================================== replaying
+
+ /**
+ * The whole point, in one test: buttons written down as they were pressed, played back into a
+ * machine that has never seen them, arriving at the same bytes.
+ */
+ @Test
+ void aRecordedRunReplaysToByteIdenticalState() throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, PRESS_AT, 0);
+ play(nes, recorder, PRESS_FOR, Controller.BUTTON_START);
+ play(nes, recorder, 20, 0);
+
+ var movie = roundTrip(recorder.movie());
+
+ assertEquals(PRESS_AT + PRESS_FOR + 20, movie.frameCount());
+
+ var replayed = load();
+ replay(replayed, movie, movie.frameCount());
+
+ assertArrayEquals(save(nes), save(replayed), "every field, not only the picture");
+ }
+
+ /**
+ * A movie carries the timeline that was finally played and never the one that was taken back.
+ *
+ * This is exact rather than a convenient approximation, and it composes out of something already
+ * proved elsewhere: {@link RewindTests#rewindingGoesBackToTheFrameItLeft} shows a rewound machine
+ * is the machine that never went forward, so truncating the log to match is not losing
+ * information -- there was none to lose.
+ */
+ @Test
+ void aRewindWhileRecordingLeavesOnlyTheFinalTimeline() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY);
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ rewind.capture(nes);
+
+ for (var i = 0; i < 110; i++) {
+ nes.getController1().setButtons(0);
+ advanceFrame(nes);
+ rewind.capture(nes);
+ recorder.frame(0);
+ }
+
+ var moved = rewind.rewind(nes, 30);
+ recorder.rewound(nes, moved);
+
+ assertEquals(30, moved);
+ assertEquals(80, nes.getPPU().getFrame());
+ assertEquals(80, recorder.framesRecorded(), "the thirty that were taken back are gone");
+ assertFalse(recorder.anchored(), "and it still starts where it always did");
+
+ // The same frames again, played differently. Without this the two timelines would be one
+ // and the test would prove nothing.
+ for (var i = 0; i < 30; i++) {
+ nes.getController1().setButtons(Controller.BUTTON_START);
+ advanceFrame(nes);
+ rewind.capture(nes);
+ recorder.frame(Controller.BUTTON_START);
+ }
+
+ var movie = roundTrip(recorder.movie());
+
+ assertEquals(110, movie.frameCount());
+ assertEquals(0, movie.buttonsAt(79), "frame 79 was played with nothing held");
+ assertEquals(Controller.BUTTON_START, movie.buttonsAt(80), "and 80 was not");
+
+ var replayed = load();
+ replay(replayed, movie, movie.frameCount());
+
+ assertArrayEquals(save(nes), save(replayed),
+ "the replay never re-enacts the thirty frames that were undone");
+ }
+
+ /**
+ * Going back further than the recording itself. There is no longer a log that describes where
+ * the machine is, so the recording starts again from where it now stands -- which keeps the take
+ * alive for whatever is played next, at the cost of what came before.
+ */
+ @Test
+ void aRewindPastTheStartReanchorsTheRecording() throws IOException {
+ var nes = load();
+ var rewind = new Rewind(ROOMY);
+
+ rewind.capture(nes);
+
+ for (var i = 0; i < 40; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ }
+
+ // Recording starts forty frames in, which is what gives the rewind below somewhere to go
+ // that the log cannot describe.
+ var recorder = MovieRecorder.anchoredAt(nes, List.of());
+
+ for (var i = 0; i < 10; i++) {
+ advanceFrame(nes);
+ rewind.capture(nes);
+ recorder.frame(0);
+ }
+
+ var moved = rewind.rewind(nes, 30);
+ recorder.rewound(nes, moved);
+
+ assertEquals(30, moved);
+ assertEquals(20, nes.getPPU().getFrame());
+ assertEquals(0, recorder.framesRecorded(), "the log could not describe frame 20, so it went");
+ assertTrue(recorder.anchored());
+ assertEquals(20, recorder.anchorFrame(), "and the recording starts where the machine is");
+
+ play(nes, recorder, 25, Controller.BUTTON_START);
+
+ var movie = roundTrip(recorder.movie());
+
+ assertEquals(25, movie.frameCount());
+ assertEquals(20, movie.anchorFrame());
+
+ var replayed = load();
+ replay(replayed, movie, movie.frameCount());
+
+ assertEquals(45, replayed.getPPU().getFrame(), "twenty anchored plus twenty-five played");
+ assertArrayEquals(save(nes), save(replayed));
+ }
+
+ /**
+ * The console's Reset button, which is a thing that happened to the machine rather than a button
+ * on the pad -- so it is recorded separately and applied at the start of the frame it was
+ * pressed at.
+ */
+ @Test
+ void aResetIsReplayedOnTheFrameItHappened() throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, 40, 0);
+
+ // Told before the machine is, so the index written down is the frame the reset is seen in.
+ recorder.reset();
+ nes.reset();
+
+ play(nes, recorder, 40, 0);
+
+ var movie = roundTrip(recorder.movie());
+
+ assertArrayEquals(new long[]{40}, movie.resets());
+ assertTrue(movie.resetsAt(40));
+ assertFalse(movie.resetsAt(39));
+
+ var straight = load();
+ for (var i = 0; i < 80; i++) {
+ advanceFrame(straight);
+ }
+
+ assertFalse(
+ Arrays.equals(save(straight), save(nes)),
+ "the reset has to have changed something, or this test proves nothing");
+
+ var replayed = load();
+ replay(replayed, movie, movie.frameCount());
+
+ assertArrayEquals(save(nes), save(replayed));
+ }
+
+ /**
+ * A loaded state is a machine nobody played their way to, so there is no timeline to truncate
+ * and the movie starts again from where the state put it.
+ */
+ @Test
+ void aLoadedStateWhileRecordingStartsTheMovieThere() throws IOException {
+ var elsewhere = load();
+ for (var i = 0; i < 50; i++) {
+ advanceFrame(elsewhere);
+ }
+
+ var bookmark = save(elsewhere);
+
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, 10, 0);
+
+ SaveState.read(nes, new ByteArrayInputStream(bookmark));
+ recorder.jumped(nes);
+
+ assertTrue(recorder.anchored(), "a power-on movie stops being one the moment it jumps");
+ assertEquals(50, recorder.anchorFrame());
+ assertEquals(0, recorder.framesRecorded());
+
+ play(nes, recorder, 20, Controller.BUTTON_START);
+
+ var movie = roundTrip(recorder.movie());
+ var replayed = load();
+
+ replay(replayed, movie, movie.frameCount());
+
+ assertEquals(70, replayed.getPPU().getFrame());
+ assertArrayEquals(save(nes), save(replayed));
+ }
+
+ /**
+ * The smaller of the two files, and the more portable: buttons and nothing else. Which is also
+ * why it can only be recorded from a machine that has not run -- there is nothing in it to say
+ * where "the beginning" was.
+ */
+ @Test
+ void aPowerOnMovieEmbedsNoAnchor() throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, 300, 0);
+
+ var movie = roundTrip(recorder.movie());
+
+ assertFalse(movie.anchored());
+ assertEquals(0, movie.anchorFrame());
+
+ var started = load();
+ movie.applyAnchor(started);
+
+ assertEquals(0, started.getPPU().getFrame(), "nothing was put back, because nothing had to be");
+
+ var alreadyRunning = load();
+ advanceFrame(alreadyRunning);
+
+ var refused = assertThrows(
+ MovieException.class, () -> movie.applyAnchor(alreadyRunning));
+
+ assertTrue(refused.getMessage().contains("power on"));
+ }
+
+ @Test
+ void aMovieCannotBeRecordedFromPowerOnAfterTheMachineHasRun() throws IOException {
+ var nes = load();
+ advanceFrame(nes);
+
+ assertThrows(MovieException.class, () -> MovieRecorder.atPowerOn(nes, List.of()));
+ }
+
+ @Test
+ void anAnchoredMovieOpensWhereItWasAnchored() throws IOException {
+ var nes = load();
+
+ for (var i = 0; i < 120; i++) {
+ advanceFrame(nes);
+ }
+
+ var atTheAnchor = save(nes);
+ var recorder = MovieRecorder.anchoredAt(nes, List.of());
+
+ play(nes, recorder, 30, 0);
+
+ var movie = roundTrip(recorder.movie());
+ var opened = load();
+
+ movie.applyAnchor(opened);
+
+ assertEquals(120, opened.getPPU().getFrame());
+ assertArrayEquals(atTheAnchor, save(opened), "the whole machine, not just the frame count");
+ }
+
+ /**
+ * A replay longer than the movie is a legitimate thing to want -- what does the game do when the
+ * player stops playing? -- and the honest answer for a frame nobody recorded is that nobody was
+ * touching the pad.
+ */
+ @Test
+ void runningPastTheEndPressesNothing() throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, 10, Controller.BUTTON_A);
+
+ var movie = recorder.movie();
+
+ assertEquals(Controller.BUTTON_A, movie.buttonsAt(9));
+ assertEquals(0, movie.buttonsAt(10));
+ assertEquals(0, movie.buttonsAt(9999));
+ assertEquals(0, movie.buttonsAt(-1));
+ }
+
+ // ================================================================================== the file
+
+ @Test
+ void aMovieWrittenAndReadIsTheSameMovie() throws IOException {
+ var nes = load();
+ var codes = List.of(GameGenieCode.decode("SXIOPO"), GameGenieCode.decode("IKAEAUAK"));
+ var recorder = MovieRecorder.atPowerOn(nes, codes);
+
+ play(nes, recorder, 20, 0);
+ recorder.reset();
+ nes.reset();
+ play(nes, recorder, 20, Controller.BUTTON_B | Controller.BUTTON_LEFT);
+
+ var path = directory.resolve("take.mnm");
+ recorder.movie().write(path);
+
+ var read = Movie.read(path);
+
+ assertEquals(Movie.VERSION, read.header().formatVersion());
+ assertEquals(nes.getCart().sha256(), read.header().romSHA256());
+ assertEquals(0, read.header().mapperNumber());
+ assertEquals(Region.NTSC, read.header().region());
+ assertEquals(1, read.header().ports());
+ assertEquals(40, read.frameCount());
+ assertFalse(read.anchored());
+ assertArrayEquals(new long[]{20}, read.resets());
+ assertEquals(0, read.buttonsAt(19));
+ assertEquals(Controller.BUTTON_B | Controller.BUTTON_LEFT, read.buttonsAt(20));
+ assertEquals(
+ List.of("SXIOPO", "IKAEAUAK"),
+ read.genie().stream().map(GameGenieCode::text).toList(),
+ "the codes are pinned, since nothing about the cartridge says they were in");
+ }
+
+ /**
+ * What lets a chooser say "1,203 frames, from a state at frame 4,201" for a directory of files
+ * without inflating any of them -- and what lets a movie from the wrong cartridge be refused
+ * before it is opened properly.
+ */
+ @Test
+ void theHeaderCanBeReadWithoutInflatingTheRest() throws IOException {
+ var nes = load();
+
+ for (var i = 0; i < 42; i++) {
+ advanceFrame(nes);
+ }
+
+ var recorder = MovieRecorder.anchoredAt(nes, List.of());
+ play(nes, recorder, 17, 0);
+
+ var path = directory.resolve("anchored.mnm");
+ recorder.movie().write(path);
+
+ var header = Movie.header(path);
+
+ assertEquals(Movie.VERSION, header.formatVersion());
+ assertEquals(nes.getCart().sha256(), header.romSHA256());
+ assertEquals(Region.NTSC, header.region());
+ assertTrue(header.anchored());
+ assertEquals(42, header.anchorFrame());
+ assertEquals(17, header.frameCount());
+ }
+
+ @Test
+ void aMovieFromAnotherCartridgeIsRefused() throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, 5, 0);
+
+ var movie = recorder.movie();
+ var other = load("/mmc3-test-2/1-clocking.nes");
+ var before = save(other);
+
+ var refused = assertThrows(MovieException.class, () -> movie.applyAnchor(other));
+
+ assertTrue(refused.getMessage().contains("another cartridge"));
+ assertArrayEquals(before, save(other), "and the machine was not touched");
+ }
+
+ /**
+ * A movie is a count of frames and a frame is not the same length on the two machines, so this
+ * is refused rather than converted: the buttons would drift apart from the first second.
+ */
+ @Test
+ void aMovieFromTheOtherMachineIsRefused() throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, 5, 0);
+
+ var movie = recorder.movie();
+ var pal = new NES(nes.getCart(), Region.PAL);
+
+ var refused = assertThrows(MovieException.class, () -> movie.applyAnchor(pal));
+
+ assertTrue(refused.getMessage().contains("PAL"));
+ }
+
+ @Test
+ void aMovieFromALaterVersionIsRefused() throws IOException {
+ var file = recorded(10);
+
+ // The version sits at offset 8, outside the compression, so this is one byte.
+ file[9] = (byte) (Movie.VERSION + 1);
+
+ var refused = assertThrows(
+ MovieException.class, () -> Movie.read(new ByteArrayInputStream(file)));
+
+ assertTrue(refused.getMessage().contains("version " + (Movie.VERSION + 1)));
+ }
+
+ @Test
+ void aFileThatIsNotAMovieIsRefused() {
+ assertThrows(MovieException.class,
+ () -> Movie.read(new ByteArrayInputStream("not a movie at all".getBytes(
+ StandardCharsets.US_ASCII))));
+
+ assertThrows(MovieException.class,
+ () -> Movie.read(new ByteArrayInputStream(new byte[0])));
+ }
+
+ @Test
+ void aDamagedMovieIsRefusedRatherThanPlayedPartWay() throws IOException {
+ var file = recorded(30);
+
+ // Well inside the gzipped body, so the CRC32 at the end of it catches this.
+ file[Movie.HEADER_BYTES + 20] ^= 0xFF;
+
+ assertThrows(MovieException.class, () -> Movie.read(new ByteArrayInputStream(file)));
+ }
+
+ @Test
+ void aMovieWhoseFrameCountDisagreesWithItsButtonsIsRefused() throws IOException {
+ var file = recorded(30);
+
+ // Offset 52, and outside the compression like everything else in the header.
+ file[59] = (byte) 31;
+
+ var refused = assertThrows(
+ MovieException.class, () -> Movie.read(new ByteArrayInputStream(file)));
+
+ assertTrue(refused.getMessage().contains("31 frames"));
+ }
+
+ @Test
+ void aChunkThisVersionHasNeverHeardOfIsSteppedOver() throws IOException {
+ var file = withExtraChunk(recorded(25), "ZZZZ", new byte[]{9, 9, 9, 9, 9});
+ var movie = Movie.read(new ByteArrayInputStream(file));
+
+ assertEquals(25, movie.frameCount(), "the chunks it does know still landed");
+ }
+
+ @Test
+ void aMovieCarryingSomethingThatIsNotACodeIsRefused() throws IOException {
+ var file = withExtraChunk(
+ recorded(5), "GENI", new byte[]{6, 'G', 'O', 'S', 'S', 'I', 'B'});
+
+ assertThrows(MovieException.class, () -> Movie.read(new ByteArrayInputStream(file)));
+ }
+
+ // ================================================================================== internals
+
+ private static NES load() throws IOException {
+ return load("/nestest/nestest.nes");
+ }
+
+ private static NES load(final String resource) throws IOException {
+ try (var rom = MovieTests.class.getResourceAsStream(resource)) {
+ assertNotNull(rom, resource);
+ return new NES(Cart.load(rom.readAllBytes(), resource));
+ }
+ }
+
+ /**
+ * A session played with the pad held one way throughout, written down as it goes -- which is
+ * exactly the shape both front ends drive a recorder in.
+ */
+ private static void play(
+ final NES nes, final MovieRecorder recorder, final int frames, final int mask) {
+ for (var i = 0; i < frames; i++) {
+ nes.getController1().setButtons(mask);
+ advanceFrame(nes);
+ recorder.frame(mask);
+ }
+ }
+
+ /**
+ * The replay loop, in the order the reset has to happen in: pressed at the start of the frame,
+ * then the buttons for it, then the frame itself.
+ */
+ private static void replay(final NES nes, final Movie movie, final long frames) {
+ movie.applyAnchor(nes);
+
+ for (var i = 0L; i < frames; i++) {
+ if (movie.resetsAt(i)) {
+ nes.reset();
+ }
+
+ nes.getController1().setButtons(movie.buttonsAt(i));
+ advanceFrame(nes);
+ }
+ }
+
+ /**
+ * Through the file and back, so that every assertion about a replay is also an assertion that
+ * the format carried what the recorder held.
+ */
+ private static Movie roundTrip(final Movie movie) throws IOException {
+ var out = new ByteArrayOutputStream();
+
+ movie.write(out);
+
+ return Movie.read(new ByteArrayInputStream(out.toByteArray()));
+ }
+
+ private static byte[] recorded(final int frames) throws IOException {
+ var nes = load();
+ var recorder = MovieRecorder.atPowerOn(nes, List.of());
+
+ play(nes, recorder, frames, 0);
+
+ var out = new ByteArrayOutputStream();
+ recorder.movie().write(out);
+
+ return out.toByteArray();
+ }
+
+ 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);
+ }
+
+ /**
+ * Rebuilds a movie with one more chunk on the end of its body, fixing the declared length in the
+ * header so the file is well formed in every way except that this build has never heard of the
+ * tag.
+ */
+ private static byte[] withExtraChunk(
+ final byte[] file, final String tag, final byte[] payload) throws IOException {
+ var header = Arrays.copyOf(file, Movie.HEADER_BYTES);
+ final byte[] body;
+
+ try (var gzip = new GZIPInputStream(new ByteArrayInputStream(
+ Arrays.copyOfRange(file, Movie.HEADER_BYTES, file.length)))) {
+ body = gzip.readAllBytes();
+ }
+
+ var changed = new ByteArrayOutputStream();
+ changed.write(body);
+ changed.write(tag.getBytes(StandardCharsets.US_ASCII));
+ changed.write(new byte[]{0, 0, 0, (byte) payload.length});
+ changed.write(payload);
+
+ var grown = changed.toByteArray();
+
+ for (var i = 0; i < 4; i++) {
+ header[64 + i] = (byte) (grown.length >> (24 - i * 8));
+ }
+
+ var out = new ByteArrayOutputStream();
+ out.write(header);
+
+ try (var gzip = new GZIPOutputStream(out)) {
+ gzip.write(grown);
+ }
+
+ return out.toByteArray();
+ }
+
+ /**
+ * Kept honest about where the movie files land: a temporary directory, never beside a fixture.
+ */
+ @Test
+ void aMovieIsWrittenWhereItWasAsked() throws IOException {
+ var path = directory.resolve("nested").resolve("take.mnm");
+
+ Movie.read(new ByteArrayInputStream(recorded(3))).write(path);
+
+ assertTrue(Files.exists(path));
+ assertEquals(3, Movie.header(path).frameCount());
+ }
+}
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 c27f1ab..b516a42 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
@@ -1,16 +1,24 @@
package com.github.dimiro1.mynes.ui;
import com.github.dimiro1.mynes.NES;
+import com.github.dimiro1.mynes.cheat.GameGenieCode;
import com.github.dimiro1.mynes.debug.Debugger;
+import com.github.dimiro1.mynes.state.Movie;
+import com.github.dimiro1.mynes.state.MovieException;
+import com.github.dimiro1.mynes.state.MovieRecorder;
import com.github.dimiro1.mynes.state.Rewind;
import org.jetbrains.annotations.Nullable;
import javax.swing.SwingUtilities;
+import java.io.IOException;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
+import java.nio.file.Path;
+import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.locks.LockSupport;
import java.util.function.Consumer;
+import java.util.function.IntSupplier;
/**
* Runs a {@link NES} on its own thread, one frame at a time, and hands the finished frames to a
@@ -23,6 +31,14 @@
* 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.
*
+ * And it writes sessions down, and plays them back. A {@link MovieRecorder} is offered the mask that
+ * was in force for every finished frame -- the same frames the two rewind rings are fed on, since
+ * all three have to agree about what a frame is -- and a {@link Movie} being played supplies that
+ * mask instead of the keyboard. Both of them move the pad from the immediate path to a latch on this
+ * thread, once a frame, which is what {@code KeyboardInput.setLatching} is for: a key that reached
+ * the controller half way through a frame would be recorded as belonging to a frame it was only half
+ * of.
+ *
* 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
@@ -151,6 +167,49 @@ public class EmulatorRunner {
*/
private volatile Consumer
+ * Always anchored, unlike the command line's: somebody who has just decided to record something
+ * is hardly ever sitting on a machine that has not run yet, and a menu item that behaved
+ * differently depending on whether they were would be a menu item nobody could predict.
+ *
+ * @param codes the Game Genie codes in the slot, pinned into the movie's header. The window
+ * refuses to change them while this is running, since a movie whose header names
+ * one set and whose frames were played against another cannot be replayed.
+ */
+ public void startRecording(final List
+ * A state change rather than a plain command: the anchor replaces the machine wholesale, and
+ * what the sound card is still holding belongs to a game that is no longer running.
+ */
+ public void startPlayback(final Movie movie) {
+ postStateChange(() -> {
+ try {
+ movie.applyAnchor(nes);
+ } catch (MovieException e) {
+ // The window checked the header before opening the file, so this is close to
+ // impossible -- and a machine left half started would be worse than a log line.
+ // Told anyway, or the window sits with the keyboard muted waiting for a playback
+ // that never began.
+ logger.log(Level.ERROR, "could not start the movie", e);
+ notePlaybackEnded();
+ return;
+ }
+
+ playing = movie;
+ playCursor = 0;
+ atFrameBoundary = true;
+
+ // A movie of no frames is legal and boring, and it is over before the first one runs.
+ // Ended here rather than left to the loop, so the window is told either way.
+ if (playCursor >= playing.frameCount()) {
+ endPlayback();
+ }
+ });
+ }
+
+ /**
+ * Gives up on a movie part way through, leaving the machine wherever it had got to. What
+ * reaching for the rewind key does, and what the menu item does.
+ */
+ public void stopPlayback() {
+ post(this::endPlayback);
+ }
+
+ /**
+ * The console's Reset button, and the one way the window presses it.
+ *
+ * One posted command rather than two, because a recorder has to be told before the machine is
+ * and the two threads cannot be trusted to keep that order between them.
+ */
+ public void reset() {
+ post(() -> {
+ if (recorder != null) {
+ recorder.reset();
+ }
+
+ nes.reset();
+ });
+ }
+
+ /**
+ * The machine has been replaced wholesale by something nobody played their way to -- a loaded
+ * slot. Called from inside the runnable that did it, so a recording in progress starts again
+ * from where the state put it rather than carrying on describing a timeline that no longer
+ * leads anywhere.
+ */
+ public void noteMachineJumped() {
+ if (recorder != null) {
+ recorder.jumped(nes);
+ }
+
+ atFrameBoundary = true;
+ }
+
/**
* Runs exactly one instruction, whether the machine is paused or not.
*/
@@ -407,6 +596,13 @@ private void run() {
// 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) {
+ // Reaching for rewind during a replay is how somebody says "let me take it from
+ // here": the movie stops and the machine is theirs. Anything else would be a
+ // replay fighting the player for the same frames.
+ if (playing != null) {
+ endPlayback();
+ }
+
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.
@@ -430,6 +626,15 @@ private void run() {
// has just gone back over.
var given = (int) (wasOn - ppu.getFrame());
+ // Frames rather than the states the call above answered in: this ring keeps
+ // one every other frame, so the two numbers are different here in a way they
+ // are not in a headless session.
+ if (recorder != null) {
+ recorder.rewound(nes, given);
+ }
+
+ atFrameBoundary = true;
+
// 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
@@ -487,6 +692,25 @@ private void run() {
}
}
+ // The pad, changed exactly once per frame and on this thread, whenever a movie is
+ // involved. Skipped on a frame that is being resumed after a breakpoint stopped it
+ // part way through: latching again in flight would change what the game is holding
+ // inside a single frame, which is a frame neither a recording nor a replay could
+ // describe.
+ if (atFrameBoundary) {
+ if (playing != null) {
+ if (playing.resetsAt(playCursor)) {
+ nes.reset();
+ }
+
+ pendingMask = playing.buttonsAt(playCursor);
+ nes.getController1().setButtons(pendingMask);
+ } else if (recorder != null) {
+ pendingMask = inputSource.getAsInt();
+ nes.getController1().setButtons(pendingMask);
+ }
+ }
+
Debugger.Stop stop = null;
if (debugger.isArmed()) {
@@ -503,6 +727,8 @@ private void run() {
var completed = ppu.getFrame() != lastFrame;
+ atFrameBoundary = completed;
+
// 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
@@ -520,6 +746,24 @@ private void run() {
rewindAudio.capture(samples, sampleCount);
}
+ // The same gate, deliberately: a movie, the rewind ring and the sound ring have to
+ // be fed on exactly the same frames or none of the three describes the same second
+ // of the game as the others.
+ if (completed && recorder != null) {
+ recorder.frame(pendingMask);
+ }
+
+ if (completed && playing != null) {
+ playCursor++;
+
+ if (playCursor >= playing.frameCount()) {
+ // Straight back to the keyboard, with no pause and no dialog: the frame
+ // after the last frame of a replay is the first frame of a game somebody is
+ // playing.
+ endPlayback();
+ }
+ }
+
if (stop != null) {
halt(stop);
}
@@ -664,6 +908,35 @@ private void halt(final Debugger.Stop stop) {
}
}
+ /**
+ * Drops the movie and tells the window, which is what gives the keyboard back.
+ *
+ * The listener is told on the event dispatch thread, and the hop is made here rather than left
+ * to whoever registered, for the reason {@link #halt} makes it here: this is the one place that
+ * can be sure of it.
+ */
+ private void endPlayback() {
+ if (playing == null) {
+ return;
+ }
+
+ logger.log(Level.INFO, "playback ended at frame " + nes.getPPU().getFrame()
+ + ", " + playCursor + " of " + playing.frameCount() + " frames played");
+
+ playing = null;
+ playCursor = 0;
+
+ notePlaybackEnded();
+ }
+
+ private void notePlaybackEnded() {
+ var listener = playbackEndedListener;
+
+ if (listener != null) {
+ SwingUtilities.invokeLater(listener);
+ }
+ }
+
private void runPendingCommands() {
Runnable command;
while ((command = commands.poll()) != null) {
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 693f228..da7a765 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,8 @@
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.Movie;
+import com.github.dimiro1.mynes.state.MovieException;
import com.github.dimiro1.mynes.state.Rewind;
import com.github.dimiro1.mynes.state.SaveState;
import com.github.dimiro1.mynes.state.SaveStateException;
@@ -83,6 +85,13 @@ public class GameUIFrame extends JFrame {
*/
private final SystemFileChooser patchChooser;
+ /**
+ * The Record and Play dialogs, kept apart from the two above so that movies remember their own
+ * folder: a session somebody recorded is not usually filed beside the cartridge it was recorded
+ * from.
+ */
+ private final SystemFileChooser movieChooser;
+
private final ScreenComponent screen = new ScreenComponent();
private final KeyboardInput keyboardInput;
@@ -120,6 +129,26 @@ public class GameUIFrame extends JFrame {
private final JMenuItem machineMenuQuickSave = new JMenuItem("Quick Save");
private final JMenuItem machineMenuQuickLoad = new JMenuItem("Quick Load");
+ /**
+ * The four movie items, and the two things a movie will not survive.
+ *
+ * Power Cycle and Region both build a new machine, and a take in progress lives in the runner
+ * that would be torn down -- so both are greyed out while one is running rather than allowed to
+ * lose it silently. There is no accelerator on any of these: the function keys are spoken for,
+ * and Shift is Select, so a Shift shortcut is a hazard here.
+ */
+ private final JMenuItem machineMenuRecord = new JMenuItem("Record Movie...");
+ private final JMenuItem machineMenuStopRecording = new JMenuItem("Stop Recording");
+ private final JMenuItem machineMenuPlay = new JMenuItem("Play Movie...");
+ private final JMenuItem machineMenuStopPlayback = new JMenuItem("Stop Playback");
+ private final JMenuItem machineMenuPowerCycle = new JMenuItem("Power Cycle", KeyEvent.VK_C);
+
+ /**
+ * Built in {@link #init()} rather than here, because {@link #regionMenu()} reads the config and
+ * a field initialiser runs before the constructor has loaded it.
+ */
+ private JMenu machineMenuRegion;
+
/**
* Screenshot, kept because it is the one item in an always-enabled menu that needs a machine.
* There is nothing to photograph until one is running, and a File menu greyed out as a whole
@@ -185,6 +214,30 @@ public class GameUIFrame extends JFrame {
*/
private byte[] batteryShadow = new byte[0];
+ /**
+ * Whether a movie is being recorded, and where it is going when it stops.
+ *
+ * The destination is asked for up front, before a single frame is recorded, which is the shape
+ * {@code --record FILE} has and the shape that means somebody who forgets to stop cleanly has
+ * still said where it goes. The recorder itself lives on the emulation thread; these two are the
+ * window's own copy of "is one running", since it cannot read that field.
+ */
+ private boolean movieRecording;
+
+ private Path recordingTo;
+
+ private boolean moviePlaying;
+
+ /**
+ * A movie waiting for the machine that is about to be built to start playing it.
+ *
+ * Playback always goes through a power cycle, whether the movie is anchored or not: a movie from
+ * power on needs a machine that has not run, and one with a state inside it is going to replace
+ * the machine anyway. One path rather than two, and the codes and the muting are put in place in
+ * {@link #startMachine} where every other per-machine thing already is.
+ */
+ private Movie pendingMovie;
+
public GameUIFrame() {
super("MyNES");
@@ -198,6 +251,11 @@ public GameUIFrame() {
patchChooser.addChoosableFileFilter(patchFilter);
patchChooser.setFileFilter(patchFilter);
+ var movieFilter = new SystemFileChooser.FileNameExtensionFilter("MyNES movie", "mnm");
+ movieChooser = new SystemFileChooser();
+ movieChooser.addChoosableFileFilter(movieFilter);
+ movieChooser.setFileFilter(movieFilter);
+
config = Config.load(Config.DEFAULT_PATH);
keyboardInput = new KeyboardInput(this, config.keyBindings());
@@ -253,12 +311,12 @@ private void init() {
machineMenuReset.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_R, command));
machineMenu.add(machineMenuReset);
- JMenuItem machineMenuPowerCycle = new JMenuItem("Power Cycle", KeyEvent.VK_C);
machineMenuPowerCycle.setAccelerator(
KeyStroke.getKeyStroke(KeyEvent.VK_R, command | InputEvent.SHIFT_DOWN_MASK));
machineMenu.add(machineMenuPowerCycle);
- machineMenu.add(regionMenu());
+ machineMenuRegion = regionMenu();
+ machineMenu.add(machineMenuRegion);
machineMenu.addSeparator();
@@ -278,6 +336,24 @@ private void init() {
machineMenu.addSeparator();
+ // Beside the save states, because they answer the two halves of the same question: a slot
+ // is where the machine got to and a movie is how it got there.
+ machineMenuRecord.setMnemonic(KeyEvent.VK_E);
+ machineMenuRecord.setEnabled(false);
+ machineMenu.add(machineMenuRecord);
+
+ machineMenuStopRecording.setEnabled(false);
+ machineMenu.add(machineMenuStopRecording);
+
+ machineMenuPlay.setMnemonic(KeyEvent.VK_Y);
+ machineMenuPlay.setEnabled(false);
+ machineMenu.add(machineMenuPlay);
+
+ machineMenuStopPlayback.setEnabled(false);
+ machineMenu.add(machineMenuStopPlayback);
+
+ machineMenu.addSeparator();
+
machineMenuPause.setMnemonic(KeyEvent.VK_P);
machineMenuPause.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_P, command));
machineMenu.add(machineMenuPause);
@@ -413,7 +489,10 @@ private void init() {
// vector. Posted rather than called because only the emulation thread touches the NES.
machineMenuReset.addActionListener(e -> {
if (runner != null) {
- runner.post(nes::reset);
+ // Through the runner rather than posted straight at the machine, because a movie
+ // being recorded has to be told about a reset before the machine sees it -- and one
+ // posted command is the only way to be sure of that order.
+ runner.reset();
}
});
@@ -428,6 +507,18 @@ private void init() {
machineMenuQuickSave.addActionListener(e -> saveSlot(currentSlot));
machineMenuQuickLoad.addActionListener(e -> loadSlot(currentSlot));
+ machineMenuRecord.addActionListener(e -> startRecording());
+ machineMenuStopRecording.addActionListener(e -> stopRecording());
+ machineMenuPlay.addActionListener(e -> playMovie());
+
+ // Only the runner is told: it ends the playback and hands the news back here, which is the
+ // same path the last frame of a movie takes. One place decides what stopping looks like.
+ machineMenuStopPlayback.addActionListener(e -> {
+ if (runner != null) {
+ runner.stopPlayback();
+ }
+ });
+
// Which slots have something in them changes as the game is played, so the labels are worked
// out when the menu opens rather than when it is built.
machineMenu.addMenuListener(new MenuListener() {
@@ -1002,6 +1093,11 @@ private void loadSlot(final int slot) {
runner.postStateChange(() -> {
try {
SaveState.read(nes, path);
+
+ // Inside the runnable rather than beside it, so a load that was refused leaves a
+ // recording in progress describing the timeline it is still on.
+ runner.noteMachineJumped();
+
logger.log(Level.INFO, "loaded slot " + slot + ", now at frame " + nes.getPPU().getFrame());
} catch (IOException | SaveStateException ex) {
report("Could not load slot " + slot, ex);
@@ -1012,6 +1108,183 @@ private void loadSlot(final int slot) {
keyboardInput.releaseAll();
}
+ // ==================================================================================== movies
+
+ /**
+ * Starts writing the session down, having first asked where it is going.
+ *
+ * The destination up front rather than at the end, which is the shape {@code --record FILE} has:
+ * somebody who plays for twenty minutes and then closes the window has still said where the take
+ * belongs. It also means the answer to "am I recording?" is a file on disk rather than something
+ * held only in memory.
+ */
+ private void startRecording() {
+ if (runner == null || romPath == null || movieRecording || moviePlaying) {
+ return;
+ }
+
+ movieChooser.setSelectedFile(defaultMoviePath().toFile());
+
+ if (movieChooser.showSaveDialog(this) != SystemFileChooser.APPROVE_OPTION) {
+ return;
+ }
+
+ recordingTo = movieChooser.getSelectedFile().toPath();
+ movieRecording = true;
+
+ // The pad moves to the emulation thread's own latch from here on. A press that reached the
+ // controller half way through a frame would be written down as belonging to a frame it was
+ // only half of, and the replay of it would be a different game.
+ keyboardInput.setLatching(true);
+ runner.startRecording(genieCodes);
+
+ logger.log(Level.INFO, "recording to " + recordingTo.getFileName());
+
+ updateMovieItems();
+ updateTitle();
+ }
+
+ private void stopRecording() {
+ if (runner == null || !movieRecording) {
+ return;
+ }
+
+ var path = recordingTo;
+
+ // The runner has already logged whatever went wrong and hopped back to this thread, so this
+ // is only the dialog.
+ runner.stopRecording(path, ex -> JOptionPane.showMessageDialog(
+ this,
+ "Could not write " + path.getFileName() + ": " + ex.getMessage(),
+ "Error",
+ JOptionPane.ERROR_MESSAGE));
+
+ movieRecording = false;
+ recordingTo = null;
+ keyboardInput.setLatching(false);
+
+ updateMovieItems();
+ updateTitle();
+ }
+
+ /**
+ * Opens a movie and plays it, from the beginning of the machine it was recorded on.
+ *
+ * The header is read on this thread first -- sixty-eight bytes, no inflation -- so a movie from
+ * another cartridge or another machine is refused while the game somebody is playing is still
+ * playing. Only once it is going to work is the machine replaced.
+ */
+ private void playMovie() {
+ if (cart == null || movieRecording) {
+ return;
+ }
+
+ if (movieChooser.showOpenDialog(this) != SystemFileChooser.APPROVE_OPTION) {
+ return;
+ }
+
+ var path = movieChooser.getSelectedFile().toPath();
+ final Movie movie;
+
+ try {
+ var header = Movie.header(path);
+
+ if (!header.romSHA256().equals(cart.sha256())) {
+ refuseMovie(path, "It was recorded on another cartridge -- mapper "
+ + header.mapperNumber() + " " + header.romSHA256().substring(0, 12)
+ + ", where this one is mapper " + cart.mapperNumber() + " "
+ + cart.sha256().substring(0, 12) + ".");
+ return;
+ }
+
+ if (header.region() != currentRegion()) {
+ refuseMovie(path, "It was recorded on a " + header.region().label()
+ + " machine and this one is " + currentRegion().label()
+ + ". The cartridge is right, but a frame is not the same length on the two.");
+ return;
+ }
+
+ movie = Movie.read(path);
+ } catch (IOException | MovieException ex) {
+ report("Could not read " + path.getFileName(), ex);
+ return;
+ }
+
+ logger.log(Level.INFO, "playing " + path.getFileName() + ", " + movie.frameCount()
+ + " frames" + (movie.anchored() ? " from a state inside it" : " from power on")
+ + (movie.genie().isEmpty() ? ""
+ : ", with " + movie.genie().size() + " Game Genie codes it was recorded with"));
+
+ // Consumed by startMachine, after the codes have been replayed and before the thread starts.
+ pendingMovie = movie;
+
+ startMachine(cart);
+ }
+
+ private void refuseMovie(final Path path, final String why) {
+ JOptionPane.showMessageDialog(
+ this,
+ path.getFileName() + " will not play here.\n\n" + why,
+ "Play Movie",
+ JOptionPane.WARNING_MESSAGE);
+ }
+
+ /**
+ * The movie has run out, or somebody stopped it. Called on the event dispatch thread, from the
+ * runner, whichever of the two it was -- so there is one description of what stopping looks
+ * like.
+ *
+ * Control goes straight back to the keyboard with no pause and no dialog: the frame after the
+ * last frame of a replay is the first frame of a game somebody is now playing.
+ */
+ private void playbackEnded(final EmulatorRunner from) {
+ // Which machine's playback ended. The news arrives here a moment after the fact, and by then
+ // the runner it came from may already have been replaced -- by one that is itself playing a
+ // different movie, which this would otherwise stop before it had drawn a frame.
+ if (from != runner || !moviePlaying) {
+ return;
+ }
+
+ moviePlaying = false;
+ keyboardInput.setPlaybackMuted(false);
+ keyboardInput.setLatching(false);
+
+ updateMovieItems();
+ updateTitle();
+ }
+
+ /**
+ * Where a movie goes when nobody has said. Beside the ROM and named after it, the way the slots,
+ * the battery file and the screenshots are.
+ */
+ private Path defaultMoviePath() {
+ var name = gamePath().getFileName().toString();
+ var dot = name.lastIndexOf('.');
+
+ return gamePath().resolveSibling((dot < 0 ? name : name.substring(0, dot)) + ".mnm");
+ }
+
+ /**
+ * Which of the movie items can be used, and which two things a movie stops somebody doing.
+ *
+ * Power Cycle and Region both build a new machine, and the take lives in the runner that would
+ * be torn down. The Game Genie goes with them for a different reason: a movie pins the codes
+ * when it starts, so changing them half way through would leave a file that cannot be replayed
+ * and does not say so.
+ */
+ private void updateMovieItems() {
+ var busy = movieRecording || moviePlaying;
+
+ machineMenuRecord.setEnabled(runner != null && !busy);
+ machineMenuStopRecording.setEnabled(movieRecording);
+ machineMenuPlay.setEnabled(cart != null && !busy);
+ machineMenuStopPlayback.setEnabled(moviePlaying);
+
+ machineMenuPowerCycle.setEnabled(!busy);
+ machineMenuRegion.setEnabled(!busy);
+ hacksMenuGameGenie.setEnabled(cart != null && !busy);
+ }
+
/**
* Puts what is in each slot onto its menu item, and greys out the empty ones.
*
@@ -1183,6 +1456,21 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) {
// read back as its own progress.
saveBattery();
+ // Power Cycle and Region are greyed out while a movie is running, so the only way here with
+ // one in progress is a new cartridge -- which is a decision worth honouring rather than
+ // refusing. The take lives in the runner about to be stopped, so it goes with it; said out
+ // loud, because a recording that vanished silently would look like a bug.
+ if (movieRecording) {
+ logger.log(Level.WARNING, "a movie was being recorded and the machine is being replaced,"
+ + " so the take was dropped");
+ }
+
+ movieRecording = false;
+ recordingTo = null;
+ moviePlaying = false;
+ keyboardInput.setLatching(false);
+ keyboardInput.setPlaybackMuted(false);
+
// The slots, the battery file and the screenshots are named from these two, so a game keeps
// its saves beside it -- and a hack keeps its own beside the patch, rather than writing over
// the original's.
@@ -1231,6 +1519,14 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) {
// window as the two lines above: the runner does not exist yet, so this thread owns it.
debugger.attach(nes);
+ // A movie carries the codes it was recorded with, and they win: the cartridge a code was
+ // played against is byte for byte the cartridge it was not, so this is the only thing that
+ // can put the cheat back -- and a replay against a different set of codes is a replay of
+ // nothing.
+ if (pendingMovie != null) {
+ genieCodes = pendingMovie.genie();
+ }
+
// And so does the cartridge slot. Replayed from the window's own list rather than left to
// whatever the device happened to be holding, so that there is one answer to what the codes
// are: attach first, so a code put in here reaches this machine's MMU and not the last one's.
@@ -1269,6 +1565,26 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) {
// history, and the key must not still be rewinding a game that has been switched off.
keyboardInput.setRewind(runner::setRewinding);
+ // And so is where a recorded frame's buttons come from, for the same reason again.
+ runner.setFrameInputSource(keyboardInput::heldMask);
+
+ var playing = runner;
+ runner.setPlaybackEndedListener(() -> playbackEnded(playing));
+
+ if (pendingMovie != null) {
+ // Posted before the thread exists, so the anchor is in place before a single frame runs.
+ runner.startPlayback(pendingMovie);
+ pendingMovie = null;
+
+ moviePlaying = true;
+
+ // The keyboard is kept off the game entirely while a movie plays -- a bumped key would
+ // stop it being the recorded session -- except for rewind, which is the gesture that
+ // takes it back.
+ keyboardInput.setPlaybackMuted(true);
+ keyboardInput.setLatching(true);
+ }
+
if (debuggerFrame != null) {
debuggerFrame.setMachine(nes, runner);
}
@@ -1281,7 +1597,7 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) {
machineMenu.setEnabled(true);
debugMenu.setEnabled(true);
fileMenuScreenshot.setEnabled(true);
- hacksMenuGameGenie.setEnabled(true);
+ updateMovieItems();
updateTitle();
}
@@ -1349,6 +1665,17 @@ private String machineState() {
return " (paused)";
}
+ // Above fast forward, because what the machine is doing to a file is a bigger surprise than
+ // how fast it is going -- and a recording somebody has forgotten about is the one state
+ // worth being reminded of on every glance at the window.
+ if (moviePlaying) {
+ return " (playback)";
+ }
+
+ if (movieRecording) {
+ return " (recording)";
+ }
+
if (runner.getSpeed() != EmulationSpeed.NORMAL) {
return " (fast forward)";
}
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 654a86d..75f9dd3 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
@@ -25,6 +25,14 @@
* from it. So none of the state here is shared, and the only thing that crosses to the emulation
* thread is the button mask handed to {@link Controller#setButtons(int)}, which is that class's
* problem.
+ *
+ * Two exceptions to that, and both belong to movies. {@link #heldMask()} is read from the emulation
+ * thread once a frame, which is why the mask has a field of its own and is {@code volatile}. And
+ * {@link #setLatching(boolean)} switches off the immediate hand-off above: while a movie is being
+ * recorded or played, what the game sees has to change exactly once per frame, on the thread that
+ * clocks it -- a press that landed half way through a frame would be written down as belonging to a
+ * frame it was only half of, and a replay of it would be a different game. When neither is
+ * happening the immediate path is left exactly as it was, because that is the one a player feels.
*/
public final class KeyboardInput implements KeyEventDispatcher {
/**
@@ -48,6 +56,31 @@ public final class KeyboardInput implements KeyEventDispatcher {
*/
private int pressed;
+ /**
+ * What {@link #pressed} comes to once the opposing directions are taken out, which is the mask
+ * the game actually sees.
+ *
+ * A field rather than a local because the emulation thread reads it once a frame while a movie
+ * is being recorded. {@code volatile} for that one reader; every writer is the event dispatch
+ * thread.
+ */
+ private volatile int mask;
+
+ /**
+ * Whether the emulation thread is latching the mask itself, once a frame, instead of taking it
+ * from here the moment a key moves. True exactly while a movie is being recorded or played.
+ */
+ private boolean latching;
+
+ /**
+ * Whether the keyboard is being kept away from the game entirely, which is what a replay wants:
+ * a bumped key must not reach a machine that is playing somebody else's session back.
+ *
+ * Not the same thing as {@link #latching}. A recording wants the keys -- they are what is being
+ * recorded -- and only wants them delivered on a frame boundary.
+ */
+ private boolean playbackMuted;
+
/**
* The key that runs the game backwards while it is held, or {@link KeyBindings#UNBOUND}.
*
@@ -109,6 +142,48 @@ public void setRewind(final @Nullable Consumer
+ * Taking it back pushes whatever is held down straight away, because the last thing the game was
+ * told is whatever the last latch happened to catch -- and a button that stuck down when a
+ * recording stopped would be a button held for as long as the game ran.
+ */
+ public void setLatching(final boolean latching) {
+ this.latching = latching;
+
+ if (!latching && controller != null) {
+ controller.setButtons(mask);
+ }
+ }
+
+ /**
+ * Keeps the keyboard away from the game, which is what a replay wants. Rewind still works: it is
+ * not a button, and it is the gesture that stops a playback.
+ *
+ * The buttons are dropped either way, since neither entering nor leaving a replay should leave
+ * one held. Deliberately not {@link #releaseAll()}, which would let go of rewind as well -- and
+ * a playback is most often ended by the rewind key, which is still down at the moment this is
+ * called.
+ */
+ public void setPlaybackMuted(final boolean muted) {
+ playbackMuted = muted;
+ pressed = 0;
+ mask = 0;
+
+ if (controller != null) {
+ controller.setButtons(0);
+ }
+ }
+
/**
* 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.
@@ -119,6 +194,7 @@ public void setRewind(final @Nullable Consumer
* A sound device is not needed. {@link AudioOutput#open()} says so in the log and runs silently when
* there is not one, which is exactly what happens on the machine that runs CI.
+ *
+ * The movie tests at the end are here for the same reason as the rewind ones: {@code MovieTests}
+ * proves the recorder's own arithmetic, and this proves that the loop asks it the right questions on
+ * the right frames -- which is the one part of the feature that only exists on this thread.
*/
class EmulatorRunnerTests {
/**
@@ -61,12 +72,22 @@ class EmulatorRunnerTests {
private EmulatorRunner runner;
private ArrayBlockingQueue
+ * Both calls are posted before the thread starts, so they run before the first frame and the
+ * movie starts at frame 0 with nothing missed off the front.
+ */
+ @Test
+ void everyRecordedFrameCarriesTheMaskThatWasLatched() throws Exception {
+ var path = directory.resolve("take.mnm");
+
+ runner.setFrameInputSource(() -> Controller.BUTTON_A);
+ runner.startRecording(List.of());
+ runner.start();
+
+ waitFor(frame -> frame >= 30, "the machine never got going");
+
+ var movie = stopRecordingAndRead(path);
+
+ assertEquals(0, movie.anchorFrame());
+ assertTrue(movie.frameCount() >= 30, "it recorded what was played: " + movie.frameCount());
+
+ for (var i = 0L; i < movie.frameCount(); i++) {
+ assertEquals(Controller.BUTTON_A, movie.buttonsAt(i), "frame " + i);
+ }
+ }
+
+ /**
+ * The window keeps a state every other frame, so what {@code Rewind.rewind} answers is
+ * states and what the recorder has to be told is frames. Passing the wrong one of the two would
+ * leave the movie holding twice the frames the machine actually went back over, and the invariant
+ * asserted here is what catches it: a movie holds exactly the frames between its anchor and where
+ * the machine stands, however many of them were played twice on the way.
+ */
+ @Test
+ void rewindingWhileRecordingDropsTheFramesItTookBack() throws Exception {
+ var path = directory.resolve("rewound.mnm");
+
+ runner.setFrameInputSource(() -> 0);
+ runner.startRecording(List.of());
+ runner.start();
+
+ var played = waitFor(frame -> frame >= 60, "the machine never got going");
+
+ runner.setRewinding(true);
+ waitFor(frame -> frame <= 20, "the machine never went backwards");
+ runner.setRewinding(false);
+
+ // Paused rather than stopped: posted commands still run, so the movie can still be written,
+ // and no more frames finish -- which is what makes the frame counter below worth reading.
+ runner.setPaused(true);
+ Thread.sleep(200);
+
+ var atRest = nes.getPPU().getFrame();
+ var movie = stopRecordingAndRead(path);
+
+ assertEquals(atRest - movie.anchorFrame(), movie.frameCount());
+ assertTrue(movie.frameCount() < played,
+ "the frames that were taken back are not in it: " + movie.frameCount()
+ + " of " + played);
+ }
+
+ /**
+ * A session recorded on one machine, played back on another that has never seen a key pressed --
+ * and control handed back when it runs out, which is what the window turns into giving the
+ * keyboard its game back.
+ */
+ @Test
+ void aMoviePlaysBackAndHandsControlBackAtTheEnd() throws Exception {
+ var path = directory.resolve("take.mnm");
+
+ runner.setFrameInputSource(() -> Controller.BUTTON_A);
+ runner.startRecording(List.of());
+ runner.start();
+
+ waitFor(frame -> frame >= 20, "the machine never got going");
+
+ var movie = stopRecordingAndRead(path);
+ runner.stop();
+
+ var ended = new ArrayBlockingQueue
+ * The write happens on the emulation thread, so it is the file appearing rather than the call
+ * returning that says it is done -- and it appears whole, since a movie is written through a
+ * temporary and moved into place.
+ */
+ private Movie stopRecordingAndRead(final Path path) throws IOException, InterruptedException {
+ runner.stopRecording(path, failures::add);
+
+ var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(PATIENCE_SECONDS);
+
+ while (System.nanoTime() < deadline && !Files.exists(path)) {
+ Thread.sleep(10);
+ }
+
+ assertNull(failures.poll(), "the movie should have been written without complaint");
+ assertTrue(Files.exists(path), path + " was never written");
+
+ return Movie.read(path);
+ }
+
/**
* Waits for the frame counter to do something, and says where it got to when it does not.
*/
diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java
index 6f753c5..6d30d76 100644
--- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java
+++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java
@@ -5,6 +5,8 @@
import com.github.dimiro1.mynes.patch.IPSPatch;
import com.github.dimiro1.mynes.patch.InvalidPatchException;
import com.github.dimiro1.mynes.state.BatteryRAM;
+import com.github.dimiro1.mynes.state.Movie;
+import com.github.dimiro1.mynes.state.MovieException;
import com.github.dimiro1.mynes.state.SaveStateException;
import com.github.dimiro1.mynes.palette.Palettes;
@@ -15,6 +17,7 @@
import java.lang.System.Logger.Level;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
@@ -69,9 +72,10 @@ public static int run(final String[] args) {
try {
return runCartridge(options);
- } catch (UsageException | SaveStateException e) {
+ } catch (UsageException | SaveStateException | MovieException e) {
// A save state that will not load is a mistake on the command line in every sense that
- // matters to a script: the file named was the wrong one. No sixth exit code for it.
+ // matters to a script: the file named was the wrong one. A movie that will not play is
+ // the same mistake. No sixth exit code for either.
System.err.println(e.getMessage());
return EXIT_USAGE;
} catch (IOException e) {
@@ -126,6 +130,17 @@ private static int runCartridge(final Options options) throws IOException {
var region = options.regionFor(cart);
var palette = options.paletteFor(region);
+ // Before the machine exists, because a movie is checked from top to bottom without one --
+ // so a file that is not a movie, or is damaged, or is from a later build, stops the run
+ // rather than half-playing it. Which cartridge it belongs to is Session.beginReplay's
+ // question, and is asked below.
+ var movie = options.play() == null ? null : Movie.read(options.play());
+
+ // How long a movie is cannot be known while the command line is being read, so this is
+ // where --frames finally means something. Naming one explicitly still wins: running on past
+ // the end with nothing held is how to see what the game does when the player stops playing.
+ var frames = movie != null && !options.framesSet() ? movie.frameCount() : options.frames();
+
if (cart.timing() == Cart.Timing.DENDY && options.region() == null) {
logger.log(Level.WARNING, options.rom().getFileName()
+ " says Dendy, which is not modelled; running it as PAL");
@@ -134,7 +149,7 @@ private static int runCartridge(final Options options) throws IOException {
logger.log(Level.INFO, "running " + options.rom().getFileName()
+ ", mapper " + cart.mapperNumber()
+ ", " + region.label()
- + ", " + options.frames() + " frames");
+ + ", " + frames + " frames");
Files.createDirectories(options.outDir());
@@ -157,7 +172,14 @@ private static int runCartridge(final Options options) throws IOException {
// And a Game Genie is not machine state either, for the same reason and one more: the
// cartridge it is plugged into is untouched, so a state taken with codes in has nothing
// in it to say so.
- for (var code : options.genie()) {
+ //
+ // A replay takes them from the movie rather than from the command line, which is the
+ // whole reason a movie carries them: the cartridge a code was played against is byte
+ // for byte the cartridge it was not, so nothing else in the file could say so. --genie
+ // and --play refuse each other, so these two are never both non-empty.
+ var codes = movie != null ? movie.genie() : options.genie();
+
+ for (var code : codes) {
var replaced = session.genie().add(code);
if (replaced != null) {
@@ -166,8 +188,8 @@ private static int runCartridge(final Options options) throws IOException {
}
}
- if (!options.genie().isEmpty()) {
- logger.log(Level.INFO, "put " + options.genie().size() + " Game Genie codes in;"
+ if (!codes.isEmpty()) {
+ logger.log(Level.INFO, "put " + codes.size() + " Game Genie codes in;"
+ " the cartridge is unchanged, so run.genie rather than cart.sha256 is"
+ " what tells this run from a plain one");
}
@@ -192,12 +214,53 @@ private static int runCartridge(final Options options) throws IOException {
"started from " + options.loadState() + ", at frame " + session.frame());
}
+ // The other way of starting somewhere, and refused alongside --load-state rather than
+ // ordered against it: a movie already says where it begins.
+ if (movie != null) {
+ session.beginReplay(movie);
+ logger.log(Level.INFO, "playing " + options.play() + ", "
+ + movie.frameCount() + " frames"
+ + (movie.anchored() ? " from a state at frame " + movie.anchorFrame()
+ : " from power on"));
+ }
+
+ // Last, so the first frame it writes down is the first frame that runs. A movie that
+ // carries no state is only honest when there is nothing to carry: --load-state has put
+ // the machine somewhere, and --sram-in has filled a battery a movie has no way to hold.
+ if (options.record() != null) {
+ session.startRecording(options.loadState() == null && options.sramIn() == null);
+ }
+
var outcome = options.interactive()
? interactive(options, session)
- : oneShot(options, session);
+ : oneShot(options, session, frames, movie);
+ // Read before the movie is written, so what the report calls the run is the run and not
+ // the few milliseconds of filing that follow it.
var wallClockMillis = (System.nanoTime() - startedNanos) / 1_000_000;
+ var recorded = outcome.recorded();
+ var recordedTo = outcome.recordedTo();
+
+ if (session.recording()) {
+ // Still running when the session ended, which is the ordinary case for --record and
+ // the forgetful one for a REPL that never said "record stop".
+ recorded = session.stopRecording();
+
+ if (options.record() != null) {
+ recorded.write(options.record());
+ recordedTo = options.record();
+
+ logger.log(Level.INFO, "wrote a " + recorded.frameCount()
+ + " frame movie to " + options.record());
+ } else {
+ logger.log(Level.WARNING, "a recording of " + recorded.frameCount()
+ + " frames was still running and nowhere was named to write it to,"
+ + " so it was dropped");
+ recorded = null;
+ }
+ }
+
if (options.saveState() != null) {
session.saveState(options.saveState());
logger.log(Level.INFO, "wrote a save state to " + options.saveState());
@@ -235,6 +298,9 @@ private static int runCartridge(final Options options) throws IOException {
outcome.screenshots(),
dumps,
expectations,
+ recorded,
+ recordedTo,
+ movie,
exitCode));
publish(options, report);
@@ -249,9 +315,18 @@ private static int runCartridge(final Options options) throws IOException {
/**
* What the run itself produced, before anything is asked of it.
+ *
+ * @param recorded a movie the session itself wrote, which only an interactive one can do --
+ * {@code record stop PATH} is a command. Null otherwise, including for the
+ * ordinary {@code --record} case, which is finished off after the run.
+ * @param recordedTo where that went.
*/
private record Outcome(
- long frames, Report.StoppedBecause stoppedBecause, List
+ * Refused rather than ignored, and rather than allowed to win: a replay whose input came from
+ * somewhere other than the movie is not a replay of anything, and it would look exactly like one
+ * that worked.
+ */
+ private static void refuseWithPlay(
+ final boolean given, final String flag, final String because) {
+ if (given) {
+ throw new UsageException(
+ "--play and " + flag + " cannot both be given: " + because + ".");
+ }
+ }
+
private static String value(final String[] args, final int i, final String flag) {
if (i >= args.length) {
throw new UsageException(flag + " wants a value after it.");
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 b611ba2..fd2f405 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,8 +5,11 @@
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.Movie;
+import com.github.dimiro1.mynes.state.MovieException;
import com.github.dimiro1.mynes.state.Rewind;
import com.github.dimiro1.mynes.state.SaveStateException;
+import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
@@ -59,6 +62,9 @@ public final class Repl {
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
+ record say whether a movie is being recorded, and how long it is
+ record start start writing down what is pressed
+ record stop [PATH] stop, and write it where --record said if PATH is left off
audio peak, RMS and silence since the last audio command
help this
quit stop
@@ -110,6 +116,15 @@ public final class Repl {
private int pressRemaining;
private int pressButtons;
+ /**
+ * The last movie this session wrote and where it went, so the report can name it. Null until
+ * {@code record stop} has written one, and never touched by the {@code --record} path, which is
+ * finished off by {@link Headless} after the session has ended.
+ */
+ private @Nullable Movie recordedMovie;
+
+ private @Nullable Path recordedPath;
+
public Repl(
final Session session,
final Options options,
@@ -124,6 +139,20 @@ public Repl(
this.text = text;
}
+ /**
+ * The movie {@code record stop} wrote, or null if this session wrote none.
+ */
+ public @Nullable Movie recordedMovie() {
+ return recordedMovie;
+ }
+
+ /**
+ * Where that went.
+ */
+ public @Nullable Path recordedPath() {
+ return recordedPath;
+ }
+
/**
* Takes commands until {@code quit} or the end of the input.
*
@@ -185,6 +214,7 @@ private void dispatch(final String[] words) throws IOException {
case "save-state" -> saveState(words);
case "load-state" -> loadState(words);
case "rewind" -> rewind(words);
+ case "record" -> record(words);
case "audio" -> audio();
case "help" -> reply("help", node -> node.put("commands", HELP));
default -> error(name, "\"" + name + "\" is not a command. Try help.");
@@ -539,6 +569,17 @@ private void hack(final String[] words) {
private void genie(final String name, final String[] words) {
var device = session.genie();
+ // Every form of this except "list them" changes what is in the slot, and a movie pinned the
+ // codes at the moment it started: a file whose header names one set and whose frames were
+ // played against another cannot be replayed and would not say so. Refused rather than
+ // silently re-pinned, since which of the two somebody meant is not knowable from here.
+ if (session.recording() && changesTheCodes(name, words)) {
+ throw new UsageException(
+ "a movie is being recorded, and it pinned the Game Genie codes when it started."
+ + " Stop the recording first, or take the codes out before starting"
+ + " one.");
+ }
+
if (name.equals("ungenie")) {
if (words.length < 2) {
throw new UsageException(
@@ -593,6 +634,14 @@ private void genie(final String name, final String[] words) {
reply("genie", node -> putCodes(node, device));
}
+ /**
+ * Whether this {@code genie} or {@code ungenie} would change what is in the cartridge slot, as
+ * opposed to only listing it.
+ */
+ private static boolean changesTheCodes(final String name, final String[] words) {
+ return name.equals("ungenie") || words.length > 1;
+ }
+
private static GameGenieCode decode(final String word) {
try {
return GameGenieCode.decode(word);
@@ -708,6 +757,83 @@ private void rewind(final String[] words) {
}
}
+ /**
+ * Starts a movie, stops one, or says whether there is one.
+ *
+ * The shape of {@code rewind} rather than of {@code hack}, and for the same reason: the three
+ * forms are one idea and they read the way they are used -- {@code record start}, some frames,
+ * {@code record stop take.mnm}.
+ *
+ * Worth having as a command rather than only as a flag because this is where the claim can be
+ * checked. Record a session that rewinds half way through, play it back, and compare the two
+ * save states: a window is somebody's impression that it looked right, and here it is an
+ * assertion about bytes.
+ */
+ private void record(final String[] words) {
+ if (words.length < 2) {
+ reply("record", this::putRecord);
+ return;
+ }
+
+ switch (words[1].toLowerCase(Locale.ROOT)) {
+ case "start" -> {
+ // A movie that carries no state is only honest when there is nothing to carry: a
+ // machine that has run is somewhere a file of buttons cannot describe, and --sram-in
+ // has filled a battery a movie has no way to hold.
+ session.startRecording(session.frame() == 0 && options.sramIn() == null);
+
+ reply("record", this::putRecord);
+ }
+ case "stop" -> {
+ var path = words.length > 2 ? Path.of(words[2]) : options.record();
+
+ // Asked this way round so that a session which never started one is told that,
+ // rather than told to name a file for a movie that does not exist. Nothing has been
+ // stopped yet either way, so the take survives both refusals.
+ if (session.recording() && path == null) {
+ throw new UsageException(
+ "record stop wants somewhere to write it, as in \"record stop"
+ + " take.mnm\" -- or a --record on the command line.");
+ }
+
+ var movie = session.stopRecording();
+
+ // A file that cannot be written is a bad command rather than the end of the
+ // session, the same as a misspelled address -- and the take is still in hand, so
+ // this is the one refusal here that actually costs something.
+ try {
+ movie.write(path);
+ } catch (IOException | MovieException e) {
+ throw new UsageException("could not write " + path + ": " + e.getMessage());
+ }
+
+ recordedMovie = movie;
+ recordedPath = path;
+
+ reply("record", node -> {
+ node.put("path", path.toString());
+ node.put("frames", movie.frameCount());
+ node.put("anchored", movie.anchored());
+ node.put("anchorFrame", movie.anchorFrame());
+ node.put("bytes", sizeOf(path));
+ putRecord(node);
+ });
+ }
+ default -> throw new UsageException(
+ "record takes \"start\", \"stop\" or nothing, not \"" + words[1] + "\".");
+ }
+ }
+
+ private void putRecord(final Json.Object node) {
+ node.put("on", session.recording());
+
+ if (session.recording()) {
+ node.put("frames", session.framesRecorded());
+ node.put("anchored", session.recordingAnchored());
+ node.put("anchorFrame", session.recordingAnchorFrame());
+ }
+ }
+
private void putRewind(final Json.Object node) {
node.put("on", session.rewinding());
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 c5765cd..70ebec8 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
@@ -2,6 +2,7 @@
import com.github.dimiro1.mynes.APU;
import com.github.dimiro1.mynes.Cart;
+import com.github.dimiro1.mynes.state.Movie;
import java.nio.file.Path;
import java.time.Instant;
@@ -76,6 +77,9 @@ public record Expectation(String name, boolean passed, String detail) {
* @param screenshots the frames photographed.
* @param dumps the memories written out.
* @param expectations what was asked of the run, and whether it held.
+ * @param recorded the movie this run wrote, or null if it recorded nothing.
+ * @param recordedTo where that went.
+ * @param replayed the movie this run played, or null if it played none.
* @param exitCode what the process is about to return.
*/
public record Outcome(
@@ -87,6 +91,9 @@ public record Outcome(
List
+ * A replay is asked the movie rather than the schedule, which {@code --play} refused and which
+ * is therefore empty: answering 0 for a run that pressed something on every frame would be the
+ * one number in this document most likely to be believed.
*/
- private static long framesWithInput(final Options options, final long frames) {
+ private static long framesWithInput(final Options options, final Outcome outcome) {
var count = 0L;
- for (var frame = 0L; frame < frames; frame++) {
- if (options.input().buttonsAt(frame) != 0) {
+ for (var frame = 0L; frame < outcome.frames(); frame++) {
+ var buttons = outcome.replayed() != null
+ ? outcome.replayed().buttonsAt(frame)
+ : options.input().buttonsAt(frame);
+
+ if (buttons != 0) {
count++;
}
}
@@ -367,6 +399,23 @@ private static long framesWithInput(final Options options, final long frames) {
return count;
}
+ /**
+ * What a movie was, or the same three keys holding nulls where there was no movie. The path it
+ * came from or went to is put by the caller, since only that knows which of the two this is.
+ */
+ private static void describe(final Json.Object node, final Movie movie) {
+ if (movie == null) {
+ node.putNull("frames");
+ node.putNull("anchored");
+ node.putNull("anchorFrame");
+ return;
+ }
+
+ node.put("frames", movie.frameCount());
+ node.put("anchored", movie.anchored());
+ node.put("anchorFrame", movie.anchorFrame());
+ }
+
private static double framesPerSecond(final Outcome outcome) {
if (outcome.wallClockMillis() <= 0) {
return 0.0;
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 b80a51e..9be1689 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,6 +3,8 @@
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.Movie;
+import com.github.dimiro1.mynes.state.MovieRecorder;
import com.github.dimiro1.mynes.state.Rewind;
import com.github.dimiro1.mynes.state.SaveState;
import com.github.dimiro1.mynes.video.FrameAnalysis;
@@ -101,6 +103,15 @@ public record AudioStats(long samples, double peak, double rms, long silentFrame
*/
private @Nullable Rewind rewind;
+ /**
+ * The movie being written down, once somebody has asked for one, and null until then.
+ *
+ * Beside {@link #rewind} rather than inside the console for the reason that one is: a machine
+ * does not know that anybody is writing down what it does, and a log hanging off a chip would be
+ * walked into and shredded by {@code SaveStateCompletenessTests}.
+ */
+ private @Nullable MovieRecorder recorder;
+
/**
* 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
@@ -173,8 +184,16 @@ public void setButtons(final int mask) {
/**
* The console's Reset button.
+ *
+ * The one funnel for both {@code --reset-at} and the REPL's {@code reset}, which is what lets
+ * the recorder be told here rather than at each of them. Told before the machine is, so
+ * the index it writes down is the frame the reset will be seen in rather than the one before it.
*/
public void reset() {
+ if (recorder != null) {
+ recorder.reset();
+ }
+
nes.reset();
}
@@ -280,6 +299,13 @@ private Frame endOfFrame(final Debugger.Stop stop) throws IOException {
rewind.capture(nes);
}
+ // Here for the same reason, and with one more of its own: the buttons field is the mask in
+ // force whether the frame finished inside advanceFrame or inside stepInstructions, so a
+ // frame somebody stepped their way through is recorded with exactly what was held for it.
+ if (recorder != null) {
+ recorder.frame(buttons);
+ }
+
var hash = FrameAnalysis.hash(ppu.getFrameBuffer());
var changed = hash != previousHash;
previousHash = hash;
@@ -433,6 +459,12 @@ public void saveState(final Path path) throws IOException {
public void loadState(final Path path) throws IOException {
SaveState.read(nes, path);
+ // A machine nobody played their way to, so a recording in progress has to start again from
+ // here rather than carry on describing a timeline that no longer leads anywhere.
+ if (recorder != null) {
+ recorder.jumped(nes);
+ }
+
previousHash = FrameAnalysis.hash(nes.getPPU().getFrameBuffer());
}
@@ -495,6 +527,12 @@ public int rewind(final int frames) {
var moved = rewind.rewind(nes, frames);
+ // States and frames are the same number here, since a headless ring keeps one per frame --
+ // which is not true of the window's, and is why MovieRecorder.rewound takes frames.
+ if (recorder != null) {
+ recorder.rewound(nes, moved);
+ }
+
framesRewound += moved;
previousHash = FrameAnalysis.hash(nes.getPPU().getFrameBuffer());
@@ -529,6 +567,100 @@ public long framesRewound() {
return framesRewound;
}
+ // ==================================================================================== movies
+
+ /**
+ * Starts writing down what is pressed, so the run can be played again from a file.
+ *
+ * The codes in the cartridge slot are pinned here, at the start, which is why both front ends
+ * refuse to change them while a recording is running: a movie whose header names one set and
+ * whose frames were played against another cannot be replayed and would not say so.
+ *
+ * @param fromPowerOn whether to record a movie that carries no state at all. Only honest on a
+ * machine that has not run and whose cartridge RAM has not been filled from
+ * a battery file, since a movie has no way to carry either; anything else
+ * puts the machine as it stands into the file instead.
+ * @throws UsageException if it is already on, since a second call would silently throw the take
+ * away.
+ */
+ public void startRecording(final boolean fromPowerOn) {
+ if (recorder != null) {
+ throw new UsageException(
+ "a movie is already being recorded, " + recorder.framesRecorded() + " frames"
+ + " long. Stop it first if the point is to start a new one.");
+ }
+
+ recorder = fromPowerOn && frame() == 0
+ ? MovieRecorder.atPowerOn(nes, genie.codes())
+ : MovieRecorder.anchoredAt(nes, genie.codes());
+ }
+
+ /**
+ * Stops recording and hands over what was recorded.
+ *
+ * @throws UsageException if nothing was being recorded, since an empty movie and a movie of a
+ * session nobody recorded look identical from the outside.
+ */
+ public Movie stopRecording() {
+ if (recorder == null) {
+ throw new UsageException(
+ "nothing is being recorded, so there is no movie to write. Start one with"
+ + " \"record start\".");
+ }
+
+ var movie = recorder.movie();
+ recorder = null;
+
+ return movie;
+ }
+
+ /**
+ * Whether a movie is being written down.
+ */
+ public boolean recording() {
+ return recorder != null;
+ }
+
+ /**
+ * How many frames are in the movie so far, or 0 when nothing is being recorded.
+ */
+ public long framesRecorded() {
+ return recorder == null ? 0 : recorder.framesRecorded();
+ }
+
+ /**
+ * Whether the movie being recorded carries a state to start from rather than starting at power
+ * on. Can become true part way through a take: a loaded state or a rewind past the start of the
+ * recording both re-anchor it.
+ */
+ public boolean recordingAnchored() {
+ return recorder != null && recorder.anchored();
+ }
+
+ /**
+ * Which frame the movie being recorded starts on, or 0 when nothing is being recorded.
+ */
+ public long recordingAnchorFrame() {
+ return recorder == null ? 0 : recorder.anchorFrame();
+ }
+
+ /**
+ * Puts the machine where a movie starts, so its buttons can be played back into it.
+ *
+ * Here rather than in {@link Headless} for the reason {@link #loadState} is here: this is a
+ * machine jump, and {@link #previousHash} describes a picture the machine no longer has.
+ * Reseeding it belongs where it cannot be forgotten.
+ *
+ * @throws com.github.dimiro1.mynes.state.MovieException if the movie was recorded on another
+ * cartridge or another machine, in which
+ * case this one is untouched.
+ */
+ public void beginReplay(final Movie movie) {
+ movie.applyAnchor(nes);
+
+ previousHash = FrameAnalysis.hash(nes.getPPU().getFrameBuffer());
+ }
+
/**
* The bytes of one of the things {@code --dump} can name.
*
diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/GameGenieRunTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/GameGenieRunTests.java
index 42483ef..2779355 100644
--- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/GameGenieRunTests.java
+++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/GameGenieRunTests.java
@@ -209,6 +209,34 @@ void theReportSaysWhatTheMachineHoldsRatherThanWhatWasAskedFor() throws Exceptio
assertEquals(0x03, codes.get(0).get("compare").asInt());
}
+ /**
+ * The codes travel inside a movie, which is the only place they can travel: the cartridge a code
+ * was played against is byte for byte the cartridge it was not, so a replay that took its codes
+ * from the command line would be a replay of a different run -- and one that took none would
+ * quietly play the honest game and look like it had worked.
+ */
+ @Test
+ void aMovieCarriesTheCodesItWasRecordedWith() throws Exception {
+ var take = out.resolve("cheated.mnm");
+ var cheated = run("cheated", "--genie", String.join(",", CODES),
+ "--record", take.toString());
+
+ var replayed = out.resolve("replayed");
+
+ // Nobody types a code here, and --play would refuse one if they tried.
+ assertEquals(Headless.EXIT_OK, Headless.run(new String[]{
+ "--rom", ROM,
+ "--out", replayed.toString(),
+ "--quiet",
+ "--screenshot", "last",
+ "--play", take.toString()}));
+
+ assertEquals(CODES.size(), report(replayed).at("/run/genie").size(),
+ "the device was filled from the movie");
+ assertEquals(hashIn(cheated), hashIn(replayed));
+ assertArrayEquals(shotIn(cheated), shotIn(replayed), "the same PNG, byte for byte");
+ }
+
@Test
void aCodeThatIsNotOneStopsTheRunBeforeItStarts() {
assertEquals(Headless.EXIT_USAGE, Headless.run(new String[]{
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 7bb9e97..9586542 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
@@ -646,6 +646,249 @@ void aShorterBatteryFileFillsWhatItCanAndALongerOneIsCut() throws Exception {
"the first bank of a bigger board's file");
}
+ // ==================================================================================== movies
+
+ /**
+ * A run with no frame count of its own, which is what {@code --play} wants: the whole point is
+ * that the movie's own length is the answer unless somebody overrules it.
+ */
+ private int play(final String... extra) {
+ var args = new String[extra.length + 5];
+
+ args[0] = "--rom";
+ args[1] = ROM;
+ args[2] = "--out";
+ args[3] = out.toString();
+ args[4] = "--quiet";
+
+ System.arraycopy(extra, 0, args, 5, extra.length);
+
+ return Headless.run(args);
+ }
+
+ /**
+ * The whole claim, through the command line: a session played back arrives at the same machine.
+ *
+ * Compared as save state bytes rather than as pictures, because a picture stops being evidence
+ * as soon as a ROM settles down -- and because two files being byte-equal is an assertion
+ * anybody can re-run with {@code cmp}.
+ */
+ @Test
+ void aRecordedSessionReplaysToTheSameMachine() throws Exception {
+ var take = out.resolve("take.mnm");
+ var recorded = out.resolve("a.mn");
+ var replayed = out.resolve("b.mn");
+
+ assertEquals(Headless.EXIT_OK, run(
+ "--frames", "200", "--input", "60/40x3:start",
+ "--record", take.toString(), "--save-state", recorded.toString()));
+
+ assertTrue(Files.size(take) > 0, "there is a movie to play");
+ assertEquals(200, report().at("/run/record/frames").asLong());
+
+ assertEquals(Headless.EXIT_OK, play(
+ "--play", take.toString(), "--save-state", replayed.toString()));
+
+ assertEquals(200, report().at("/run/frames").asLong());
+ assertArrayEquals(
+ Files.readAllBytes(recorded), Files.readAllBytes(replayed),
+ "every field of the machine, not only the picture");
+ }
+
+ /**
+ * The headline: a rewind while recording drops the frames that were taken back, so the movie is
+ * the timeline that was finally played and the replay never re-enacts the revert.
+ *
+ * Ninety frames run, thirty given back, thirty played again with Start held -- so the session
+ * ends on frame 90 and the movie holds 90 rather than 120.
+ */
+ @Test
+ void aRecordedSessionWithARewindReplaysStraightThrough() throws Exception {
+ var take = out.resolve("rewound.mnm");
+ var recorded = out.resolve("a.mn");
+ var replayed = out.resolve("b.mn");
+
+ var script = Files.writeString(out.resolve("session.txt"), String.join("\n",
+ "record start",
+ "rewind on",
+ "run 90",
+ "rewind 30",
+ "hold start",
+ "run 30",
+ "quit") + "\n");
+
+ assertEquals(Headless.EXIT_OK, run(
+ "--script", script.toString(),
+ "--record", take.toString(),
+ "--save-state", recorded.toString()));
+
+ assertEquals(90, report().at("/run/record/frames").asLong(),
+ "ninety, not a hundred and twenty: the thirty that were undone are not in it");
+ assertEquals(30, report().at("/run/state/framesRewound").asLong());
+
+ assertEquals(Headless.EXIT_OK, play(
+ "--play", take.toString(), "--save-state", replayed.toString()));
+
+ assertEquals(90, report().at("/run/replay/frames").asLong());
+ assertEquals(0, report().at("/run/state/framesRewound").asLong(),
+ "the replay never goes backwards at all");
+ assertArrayEquals(Files.readAllBytes(recorded), Files.readAllBytes(replayed));
+ }
+
+ @Test
+ void aReplayReproducesAMidRunReset() throws Exception {
+ var take = out.resolve("reset.mnm");
+ var recorded = out.resolve("a.mn");
+ var untouched = out.resolve("c.mn");
+ var replayed = out.resolve("b.mn");
+
+ assertEquals(Headless.EXIT_OK, run(
+ "--frames", "120", "--reset-at", "60",
+ "--record", take.toString(), "--save-state", recorded.toString()));
+
+ // nestest is sitting on a menu either way, so the picture is no evidence at all here and
+ // the state bytes are. Without this the test would pass on a replay that ignored resets.
+ assertEquals(Headless.EXIT_OK, run("--frames", "120", "--save-state", untouched.toString()));
+ assertFalse(
+ Arrays.equals(Files.readAllBytes(recorded), Files.readAllBytes(untouched)),
+ "the reset has to have changed something, or this proves nothing");
+
+ assertEquals(Headless.EXIT_OK, play(
+ "--play", take.toString(), "--save-state", replayed.toString()));
+
+ assertArrayEquals(Files.readAllBytes(recorded), Files.readAllBytes(replayed));
+ }
+
+ /**
+ * A run that did not start at power on has nowhere for a movie to begin but a state, so the
+ * movie carries one -- and the replay of it is honest about not being a power-on run.
+ */
+ @Test
+ void anAnchoredRecordingEmbedsItsStart() throws Exception {
+ var bookmark = out.resolve("at-60.mn");
+ run("--frames", "60", "--save-state", bookmark.toString());
+
+ var take = out.resolve("anchored.mnm");
+ var recorded = out.resolve("a.mn");
+ var replayed = out.resolve("b.mn");
+
+ assertEquals(Headless.EXIT_OK, run(
+ "--frames", "40",
+ "--load-state", bookmark.toString(),
+ "--record", take.toString(),
+ "--save-state", recorded.toString()));
+
+ assertTrue(report().at("/run/record/anchored").asBoolean());
+ assertEquals(60, report().at("/run/record/anchorFrame").asLong());
+ assertEquals(40, report().at("/run/record/frames").asLong());
+
+ assertEquals(Headless.EXIT_OK, play(
+ "--play", take.toString(), "--save-state", replayed.toString()));
+
+ assertFalse(report().at("/run/state/startedFromPowerOn").asBoolean(),
+ "a replay of an anchored take is no more a power-on run than a --load-state is");
+ assertEquals(100, report().at("/ppu/frame").asLong(), "sixty anchored plus forty played");
+ assertArrayEquals(Files.readAllBytes(recorded), Files.readAllBytes(replayed));
+ }
+
+ @Test
+ void playDefaultsToTheMovieLength() throws Exception {
+ var take = out.resolve("take.mnm");
+
+ run("--frames", "137", "--record", take.toString());
+
+ assertEquals(Headless.EXIT_OK, play("--play", take.toString()));
+
+ assertEquals(137, report().at("/run/frames").asLong(),
+ "nobody named a length, so the movie's own is the answer");
+ }
+
+ /**
+ * Running longer than the movie is a legitimate thing to want -- what does the game do when the
+ * player stops playing? -- and the honest answer for a frame nobody recorded is that nobody was
+ * touching the pad, which {@code framesWithInput} has to say rather than counting the whole run.
+ */
+ @Test
+ void runningPastTheEndContinuesWithNoInput() throws Exception {
+ var take = out.resolve("take.mnm");
+
+ run("--frames", "100", "--input", "0-100:start", "--record", take.toString());
+
+ assertEquals(Headless.EXIT_OK, play("--play", take.toString()));
+
+ var withinTheMovie = report().at("/input/framesWithInput").asLong();
+
+ assertTrue(withinTheMovie > 0, "the recorded session pressed something");
+
+ assertEquals(Headless.EXIT_OK, play("--play", take.toString(), "--frames", "160"));
+
+ assertEquals(160, report().at("/run/frames").asLong());
+ assertEquals(withinTheMovie, report().at("/input/framesWithInput").asLong(),
+ "the sixty frames past the end had nothing held");
+ }
+
+ @Test
+ void aMovieFromAnotherCartridgeExitsTwo() throws Exception {
+ var take = out.resolve("take.mnm");
+ run("--frames", "20", "--record", take.toString());
+
+ assertEquals(Headless.EXIT_USAGE, Headless.run(new String[]{
+ "--rom", "src/test/resources/mmc3-test-2/1-clocking.nes",
+ "--out", out.toString(), "--quiet",
+ "--play", take.toString()}));
+ }
+
+ @Test
+ void aFileThatIsNotAMovieExitsTwo() throws Exception {
+ var nonsense = Files.writeString(out.resolve("nonsense.mnm"), "this is not a movie");
+
+ assertEquals(Headless.EXIT_USAGE, play("--play", nonsense.toString()));
+ }
+
+ /**
+ * Always present, with explicit nulls, so two reports line up key for key whether either run
+ * touched a movie at all.
+ */
+ @Test
+ void theReportSaysWhatWasRecordedAndWhatWasReplayed() throws Exception {
+ run();
+
+ assertTrue(report().at("/run/record/savedTo").isNull());
+ assertTrue(report().at("/run/record/frames").isNull());
+ assertTrue(report().at("/run/replay/playedFrom").isNull());
+ assertTrue(report().at("/run/replay/anchorFrame").isNull());
+
+ var take = out.resolve("take.mnm");
+ run("--record", take.toString());
+
+ assertEquals(take.toString(), report().at("/run/record/savedTo").asText());
+ assertEquals(60, report().at("/run/record/frames").asLong());
+ assertFalse(report().at("/run/record/anchored").asBoolean());
+ assertTrue(report().at("/run/replay/playedFrom").isNull(), "it played nothing");
+
+ play("--play", take.toString());
+
+ assertEquals(take.toString(), report().at("/run/replay/playedFrom").asText());
+ assertEquals(60, report().at("/run/replay/frames").asLong());
+ assertTrue(report().at("/run/record/savedTo").isNull(), "and it recorded nothing");
+ }
+
+ /**
+ * The REPL writes its own file, so the report has to name that one rather than the flag that was
+ * never given.
+ */
+ @Test
+ void aMovieStoppedInTheReplIsNamedByTheReport() throws Exception {
+ var take = out.resolve("from-the-repl.mnm");
+ var script = Files.writeString(out.resolve("session.txt"),
+ "record start\nrun 45\nrecord stop " + take + "\nquit\n");
+
+ run("--script", script.toString());
+
+ assertEquals(take.toString(), report().at("/run/record/savedTo").asText());
+ assertEquals(45, report().at("/run/record/frames").asLong());
+ }
+
private static byte[] patterned(final int length) {
var bytes = new byte[length];
diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java
index 17add44..6dacecf 100644
--- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java
+++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java
@@ -5,6 +5,7 @@
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -314,6 +315,90 @@ void theUsageExplainsThatABatteryFileIsTheInteroperableOne() {
"somebody reading --help should learn that a .sav can come from anywhere");
}
+ @Test
+ void theMovieFlagsAreWhereTheyWereNamed() {
+ var options = parse("--rom", "x.nes", "--record", "take.mnm");
+
+ assertEquals(Path.of("take.mnm"), options.record());
+ assertNull(options.play());
+
+ assertEquals(Path.of("in.mnm"), parse("--rom", "x.nes", "--play", "in.mnm").play());
+ }
+
+ @Test
+ void aRunPlaysAndRecordsNothingUnlessToldOtherwise() {
+ var options = parse("--rom", "x.nes");
+
+ assertNull(options.record());
+ assertNull(options.play());
+ }
+
+ /**
+ * How long a movie is cannot be known while the command line is being read, so {@code --frames}
+ * has to be remembered as given or not given rather than as a number: 600 and "nobody said" are
+ * the same value and different answers.
+ */
+ @Test
+ void whetherFramesWasAskedForIsRememberedSeparatelyFromTheNumber() {
+ assertFalse(parse("--rom", "x.nes").framesSet());
+ assertEquals(600, parse("--rom", "x.nes").frames());
+
+ assertTrue(parse("--rom", "x.nes", "--frames", "600").framesSet(),
+ "the default typed out is still somebody having typed it");
+ assertTrue(parse("--rom", "x.nes", "--frames", "30").framesSet());
+ }
+
+ /**
+ * Each of these is a second answer to something the movie has already answered, and a run that
+ * quietly took one of them would not be the recorded session at all.
+ */
+ @Test
+ void playRefusesTheFlagsTheMovieReplaces() {
+ var replaced = List.of(
+ List.of("--record", "out.mnm"),
+ List.of("--input", "60:start"),
+ List.of("--reset-at", "100"),
+ List.of("--genie", "SXIOPO"),
+ List.of("--load-state", "in.mn"),
+ List.of("--sram-in", "in.sav"),
+ List.of("--interactive"));
+
+ for (var flags : replaced) {
+ var args = new ArrayList<>(List.of("--rom", "x.nes", "--play", "take.mnm"));
+ args.addAll(flags);
+
+ var refused = refused(args.toArray(new String[0]));
+
+ assertTrue(refused.getMessage().contains(flags.getFirst()),
+ "the message has to name the flag that was typed: " + refused.getMessage());
+ assertTrue(refused.getMessage().contains("--play"));
+ }
+ }
+
+ /**
+ * Everything else combines. A recorded run is an ordinary run with somebody taking notes.
+ */
+ @Test
+ void recordCombinesWithEverythingElse() {
+ var options = parse(
+ "--rom", "x.nes",
+ "--record", "take.mnm",
+ "--input", "60:start",
+ "--reset-at", "100",
+ "--genie", "SXIOPO",
+ "--load-state", "in.mn",
+ "--interactive");
+
+ assertEquals(Path.of("take.mnm"), options.record());
+ assertTrue(options.interactive());
+ }
+
+ @Test
+ void theUsageExplainsThatARewindIsNotInTheMovie() {
+ assertTrue(Options.usage().contains("never re-enacts the revert"),
+ "somebody reading --help should learn what a rewind does to a recording");
+ }
+
@Test
void theReplyFormatIsLeftToBeResolvedUnlessNamed() {
assertEquals(Options.Format.AUTO, parse("--rom", "x.nes").format());
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 b8ba22e..e9939eb 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
@@ -380,6 +380,119 @@ void theHelpListsTheStateCommands() throws Exception {
assertTrue(help.contains("load-state"));
assertTrue(help.contains("rewind on"));
assertTrue(help.contains("rewind off"));
+ assertTrue(help.contains("record start"));
+ assertTrue(help.contains("record stop"));
+ }
+
+ // ==================================================================================== movies
+
+ @Test
+ void recordReportsItsStatus() throws Exception {
+ var replies = session(
+ "record", "record start", "run 20", "record", "record stop " + movie(), "quit");
+
+ assertFalse(replies.getFirst().get("on").asBoolean());
+ assertFalse(replies.getFirst().has("frames"),
+ "nothing to say about a recording that is not happening");
+
+ assertTrue(replies.get(1).get("on").asBoolean());
+ assertEquals(0, replies.get(1).get("frames").asLong());
+ assertFalse(replies.get(1).get("anchored").asBoolean(),
+ "started on frame 0 with no battery filled, so there is nothing to carry");
+
+ assertEquals(20, replies.get(3).get("frames").asLong());
+ assertFalse(replies.get(4).get("on").asBoolean(), "and it stopped");
+ }
+
+ /**
+ * A recording that started after the machine had run has nowhere to begin from but a state, so
+ * it takes one -- which is what makes {@code record start} usable at any moment rather than only
+ * before the first frame.
+ */
+ @Test
+ void recordingFromPartWayThroughCarriesAState() throws Exception {
+ var replies = session("run 40", "record start", "run 10", "record", "quit");
+
+ assertTrue(replies.get(1).get("anchored").asBoolean());
+ assertEquals(40, replies.get(1).get("anchorFrame").asLong());
+ assertEquals(10, replies.get(3).get("frames").asLong(),
+ "ten frames of movie, from frame forty of the machine");
+ }
+
+ @Test
+ void recordStartTwiceIsAnError() throws Exception {
+ var replies = session("record start", "run 10", "record start", "run 10", "quit");
+
+ assertFalse(replies.get(2).get("ok").asBoolean());
+ assertTrue(replies.get(2).get("error").asText().contains("already being recorded"),
+ "and says why, since starting again would throw the take away");
+ assertEquals(20, replies.get(3).get("frame").asLong(), "the session carried on");
+ }
+
+ @Test
+ void recordStopWithoutARecordingIsAnError() throws Exception {
+ var replies = session("record stop " + movie(), "run 10", "quit");
+
+ assertFalse(replies.getFirst().get("ok").asBoolean());
+ assertTrue(replies.getFirst().get("error").asText().contains("record start"),
+ "and says what to do");
+ assertEquals(10, replies.get(1).get("frame").asLong());
+ }
+
+ /**
+ * Nowhere named on the command line and nowhere named here, which has to be answered rather than
+ * guessed at -- and answered without losing the take.
+ */
+ @Test
+ void recordStopWithNowhereToWriteKeepsTheTake() throws Exception {
+ var replies = session("record start", "run 10", "record stop", "record", "quit");
+
+ assertFalse(replies.get(2).get("ok").asBoolean());
+ assertTrue(replies.get(3).get("on").asBoolean(), "still recording, still ten frames in");
+ assertEquals(10, replies.get(3).get("frames").asLong());
+ }
+
+ @Test
+ void recordStopWritesAMovieWhereItWasAsked() throws Exception {
+ var path = movie();
+ var reply = session("record start", "run 25", "record stop " + path, "quit").get(2);
+
+ assertEquals(path.toString(), reply.get("path").asText());
+ assertEquals(25, reply.get("frames").asLong());
+ assertTrue(reply.get("bytes").asLong() > 0);
+ assertTrue(Files.exists(path));
+ }
+
+ /**
+ * A movie pins the codes at the moment it starts, so changing them half way through would leave
+ * a file naming one set that was played against another -- and nothing in it would say so.
+ * Listing them is still allowed, since listing changes nothing.
+ */
+ @Test
+ void changingGenieCodesWhileRecordingIsRefused() throws Exception {
+ var replies = session(
+ "genie SXIOPO",
+ "record start",
+ "genie",
+ "genie ZEXPYGLA",
+ "ungenie SXIOPO",
+ "genie clear",
+ "quit");
+
+ assertTrue(replies.get(2).get("ok").asBoolean(), "listing them is not changing them");
+ assertEquals(List.of("SXIOPO"), codes(replies.get(2)));
+
+ for (var refused : List.of(replies.get(3), replies.get(4), replies.get(5))) {
+ assertFalse(refused.get("ok").asBoolean(), refused.toString());
+ assertTrue(refused.get("error").asText().contains("pinned"));
+ }
+ }
+
+ /**
+ * Where a movie goes in these tests. Never beside a fixture.
+ */
+ private Path movie() {
+ return directory.resolve("take.mnm");
}
// ==================================================================================== rewind
One rule for every jump
+ *
+ * A recording is a straight line from its anchor. Three things break the line, and all three are
+ * answered the same way -- the log describes the timeline that was finally played:
+ *
+ *
+ * Re-anchoring costs the take so far, and the alternative costs the take's correctness.
+ *
+ * What is pinned, and when
+ *
+ * The cartridge digest, the mapper number, the region and the Game Genie codes are read once, at
+ * construction. The first three cannot change under a running machine: a save state from another
+ * cartridge or another region is refused, and the chips are built around their region. The codes
+ * can, which is why both front ends refuse to change them while a recording is running -- a movie
+ * whose header pinned one set of codes and whose frames were played against another is a file that
+ * cannot be replayed and does not say so.
+ */
+public final class MovieRecorder {
+
+ /**
+ * How many frames of masks to make room for up front. Twenty seconds or so, doubled from there
+ * -- the array is one byte a frame, so even an hour of play is under a quarter of a megabyte and
+ * nothing here is worth a smarter structure.
+ */
+ private static final int INITIAL_FRAMES = 1024;
+
+ /**
+ * Roughly what one gzipped state comes to, so the buffer an anchor builds into rarely has to
+ * grow. Nothing depends on it being right.
+ */
+ private static final int EXPECTED_STATE_BYTES = 16 * 1024;
+
+ private final String romSHA256;
+ private final int mapperNumber;
+ private final Region region;
+ private final List