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. + * + *

Why this is a list of buttons and not a list of frames

+ * + * The machine is deterministic: nothing in it reads a clock or a random number, so the same + * cartridge given the same buttons on the same frames arrives at the same bytes every time. That is + * what {@code SaveStateDivergenceTests} and {@code RewindTests} are already built on, and it is what + * lets a movie be four things and nothing else -- where it started, one button mask per + * finished frame, which frames the Reset button was pressed at the start of, and the facts that + * change how a cartridge runs but live outside a save state. Ninety seconds of play is five + * and a half thousand bytes before the gzip gets to it, and a replay reproduces the run byte for + * byte rather than approximately. + *

+ * 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. + * + *

Rewinds are not in it

+ * + * Rewinding while recording truncates the log rather than appending to it, so a movie holds the + * timeline that was finally played and never the one that was taken back. This is not an + * approximation to be apologised for: a rewound machine is the machine that never went + * forward, byte for byte, which {@code RewindTests.rewindingGoesBackToTheFrameItLeft} proves + * separately. So replaying the truncated log lands exactly where the session ended up. + * + *

Instruction-level fidelity is out of scope

+ * + * Buttons are latched once per frame and a reset is applied at a frame's start. A session that + * stepped instructions and pressed Reset half way through a frame records that reset at the next + * frame boundary, and the replay applies it a fraction of a frame earlier than it happened. Every + * ordinary way of driving the machine -- a window, a one-shot run, {@code run N} in the REPL -- + * changes the pad only at frame boundaries, so this costs nothing outside a debugging session. + * + *

The file

+ * + * {@code .mnm}, and {@link SaveState}'s discipline exactly: big endian, a fixed header outside the + * gzip so {@link #header(Path)} can label a file chooser without inflating it, length-prefixed + * chunks that a later version can add to and this one steps over, and a version that is bumped only + * when something already in the format changes meaning. Everything the file can be wrong about is + * checked in {@link #read}, before there is a machine to touch. + *

+ * 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 genie; + + /** + * What a file says about itself, without inflating it. + * + * @param formatVersion the format it was written in. + * @param romSHA256 which cartridge it was recorded on, as lowercase hex. Of the patched + * image when there was a patch, since that is what actually ran. + * @param mapperNumber that cartridge's mapper, for the error message rather than for identity. + * @param region which machine it was recorded on. A movie is a count of frames and a PAL + * frame is not an NTSC one, so this is refused rather than converted. + * @param anchored whether it starts from a save state carried inside it. + * @param anchorFrame the PPU frame the recording started on. 0 for a movie from power on. + * @param frameCount how many frames it holds. + * @param ports how many controller lanes are in the body. 1 in this version. + */ + public record Header( + int formatVersion, + String romSHA256, + int mapperNumber, + Region region, + boolean anchored, + long anchorFrame, + long frameCount, + int ports) { + } + + /** + * Package-private: a movie arrives either from {@link #read} or from a {@link MovieRecorder}, + * and both are here. The arrays are taken as given rather than copied, since neither caller + * keeps a reference to one. + */ + Movie( + final Header header, + final byte[] anchor, + final byte[] player1, + final byte[] player2, + final long[] resets, + final List genie + ) { + this.header = header; + this.anchor = anchor; + this.player1 = player1; + this.player2 = player2; + this.resets = resets; + this.genie = List.copyOf(genie); + } + + public Header header() { + return header; + } + + /** + * How many frames were recorded. + */ + public long frameCount() { + return header.frameCount(); + } + + /** + * Whether it starts from a state carried inside it rather than from power on. + */ + public boolean anchored() { + return header.anchored(); + } + + /** + * The frame the recording started on, which is 0 for a movie from power on. + */ + public long anchorFrame() { + return header.anchorFrame(); + } + + /** + * What player one was holding for the {@code index}th frame of the movie, counting from zero. + *

+ * 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 genie() { + return genie; + } + + /** + * Puts the machine where the recording started. + *

+ * 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 chunks(final byte[] body) { + var chunks = new LinkedHashMap(); + var position = 0; + + while (position < body.length) { + if (body.length - position < TAG_BYTES + 4) { + throw new MovieException("that movie is damaged: it stops mid-chunk."); + } + + var tag = new String(body, position, TAG_BYTES, StandardCharsets.US_ASCII); + var length = SaveState.readInt(body, position + TAG_BYTES); + var payload = position + TAG_BYTES + 4; + + if (length < 0 || length > MAX_CHUNK_BYTES || length > body.length - payload) { + throw new MovieException( + "that movie is damaged: \"" + tag.trim() + "\" claims " + length + + " bytes and there are only " + (body.length - payload) + " left."); + } + + chunks.put(tag, Arrays.copyOfRange(body, payload, payload + length)); + + // By the declared length rather than by what anything read, which is what lets a chunk + // that has grown since this build was written be stepped over cleanly. + position = payload + length; + } + + return chunks; + } + + /** + * The reset list, checked to be exactly what a replay may binary-search: sorted, unique, and + * pointing at frames the movie actually holds. + */ + private static long[] resets(final byte[] payload, final long frameCount) { + if (payload == null || payload.length == 0) { + return new long[0]; + } + + if (payload.length % 8 != 0) { + throw new MovieException( + "that movie is damaged: its reset list is " + payload.length + + " bytes, which is not a whole number of frame numbers."); + } + + var resets = new long[payload.length / 8]; + + for (var i = 0; i < resets.length; i++) { + resets[i] = SaveState.readLong(payload, i * 8); + + if (i > 0 && resets[i] <= resets[i - 1]) { + throw new MovieException( + "that movie is damaged: its reset list is out of order at frame " + + resets[i] + "."); + } + + if (resets[i] < 0 || resets[i] >= frameCount) { + throw new MovieException( + "that movie is damaged: it says Reset was pressed at frame " + resets[i] + + ", and it only holds " + frameCount + " frames."); + } + } + + return resets; + } + + /** + * The pinned codes, decoded here rather than on the way into a machine -- so a movie carrying + * something that is not a code is refused as a damaged file rather than as a run that quietly + * played without the cheat it was recorded with. + */ + private static List codes(final byte[] payload) { + if (payload == null || payload.length == 0) { + return List.of(); + } + + var codes = new ArrayList(); + var position = 0; + + while (position < payload.length) { + var length = Byte.toUnsignedInt(payload[position++]); + + if (length == 0 || payload.length - position < length) { + throw new MovieException( + "that movie is damaged: its Game Genie list stops mid-code."); + } + + var text = new String(payload, position, length, StandardCharsets.US_ASCII); + position += length; + + try { + codes.add(GameGenieCode.decode(text)); + } catch (InvalidGameGenieCodeException e) { + throw new MovieException( + "that movie is damaged: " + e.getMessage(), e); + } + } + + return List.copyOf(codes); + } +} diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieException.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieException.java new file mode 100644 index 0000000..0767b0b --- /dev/null +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieException.java @@ -0,0 +1,20 @@ +package com.github.dimiro1.mynes.state; + +/** + * A movie that cannot be played, and why. + *

+ * 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. + * + *

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 genie; + + /** + * The whole save state the movie starts from, or null when it starts at power on. + */ + private byte[] anchor; + + private long anchorFrame; + + /** + * One mask per finished frame, of which the first {@link #recorded} are real. + */ + private byte[] buttons = new byte[INITIAL_FRAMES]; + + private int recorded; + + /** + * The frames Reset was pressed at the start of, as movie-relative indices. Strictly increasing, + * which {@link #reset} keeps true and {@link #rewound} keeps true by dropping the tail. + */ + private long[] resets = new long[0]; + + private int resetCount; + + private MovieRecorder( + final NES nes, final List codes, final byte[] anchor) { + this.romSHA256 = nes.getCart().sha256(); + this.mapperNumber = nes.getCart().mapperNumber(); + this.region = nes.getRegion(); + this.genie = List.copyOf(codes); + this.anchor = anchor; + this.anchorFrame = nes.getPPU().getFrame(); + } + + /** + * Records from power on, carrying no state at all. + *

+ * 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 codes) { + if (nes.getPPU().getFrame() != 0) { + throw new MovieException( + "a movie can only start at power on from frame 0, and this machine is at frame " + + nes.getPPU().getFrame() + "."); + } + + return new MovieRecorder(nes, codes, null); + } + + /** + * Records from here, whatever "here" is, by putting the whole machine in the file. + *

+ * 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 codes) { + return new MovieRecorder(nes, codes, capture(nes)); + } + + /** + * Writes down the mask that was in force for the frame that has just finished. + *

+ * 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 stopListener; + /** + * Told, on the event dispatch thread, when a movie reaches its last frame -- so the window can + * give the keyboard back and take the word off the title bar. + */ + private volatile Runnable playbackEndedListener; + + /** + * The session being written down, or null. Emulation thread only, like everything below it: + * every way in goes through {@link #post}. + */ + private @Nullable MovieRecorder recorder; + + /** + * The session being played back, or null. + */ + private @Nullable Movie playing; + + /** + * Which frame of {@link #playing} runs next, counted from the movie's own start. + */ + private long playCursor; + + /** + * The mask latched for the frame now running, which is what gets written down when it finishes. + * Held rather than read twice, so the frame a recorder is told about is exactly the frame the + * game saw. + */ + private int pendingMask; + + /** + * Where a latched mask comes from while recording: the keyboard, in practice. Never null, so the + * loop has nothing to check -- a machine with no keyboard pointed at it records nothing pressed, + * which is true. + */ + private IntSupplier inputSource = () -> 0; + + /** + * Whether the next time round the loop starts a frame rather than resuming one a breakpoint + * stopped part way through. The guard on the latch: changing what the game is holding half way + * through a frame would be a frame nobody could record or replay honestly. + */ + private boolean atFrameBoundary = true; + /** * How fast to run. Written from the event dispatch thread and read here, so the loop picks a * change up at the next frame boundary rather than mid-frame. @@ -285,6 +344,136 @@ public void setStopListener(final Consumer listener) { this.stopListener = listener; } + /** + * Told whenever a movie reaches its last frame, on the event dispatch thread. The machine is + * still running: the last frame of a replay is followed by the next frame of a game somebody is + * now playing themselves. + */ + public void setPlaybackEndedListener(final Runnable listener) { + this.playbackEndedListener = listener; + } + + // ==================================================================================== movies + + /** + * Where the mask comes from while a movie is being recorded, latched once a frame on this + * thread. Wired to the keyboard per machine, the way the controller and the rewind key are. + */ + public void setFrameInputSource(final IntSupplier source) { + post(() -> inputSource = source); + } + + /** + * Starts writing the session down. + *

+ * 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 codes) { + post(() -> recorder = MovieRecorder.anchoredAt(nes, codes)); + } + + /** + * Stops, and writes what was recorded. + * + * @param onFailure told on the event dispatch thread if the file could not be written, since + * {@link #post} has nothing to hand an exception back on. + */ + public void stopRecording(final Path path, final Consumer onFailure) { + post(() -> { + if (recorder == null) { + return; + } + + var movie = recorder.movie(); + recorder = null; + + try { + movie.write(path); + logger.log(Level.INFO, "wrote a " + movie.frameCount() + " frame movie to " + + path.getFileName()); + } catch (IOException | MovieException e) { + logger.log(Level.ERROR, "could not write the movie", e); + SwingUtilities.invokeLater(() -> onFailure.accept(e)); + } + }); + } + + /** + * Puts the machine where the movie starts and plays it from there. + *

+ * 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 rewind) { this.rewind = rewind; } + /** + * What the player is holding down right now, ready for the emulation thread to latch at a frame + * boundary. The one thing here that another thread may call. + */ + public int heldMask() { + return mask; + } + + /** + * Hands the timing of the pad over to the emulation thread, or takes it back. + *

+ * 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 rewind) { */ public void releaseAll() { pressed = 0; + mask = 0; if (controller != null) { controller.setButtons(0); @@ -163,19 +239,38 @@ public boolean dispatchKeyEvent(final KeyEvent e) { return false; } + if (playbackMuted) { + // Swallowed rather than merely not passed on: the whole point of a replay is + // that it is the recorded session and nothing else, and a key with a menu item + // on it as well would otherwise still act. + return true; + } + // Setting a bit that is already set is what makes the key repeat a non-event. pressed |= button.mask(); } // Releases are taken whatever else is held down, so a key let go of after reaching for // a modifier cannot leave its button stuck. - case KeyEvent.KEY_RELEASED -> pressed &= ~button.mask(); + case KeyEvent.KEY_RELEASED -> { + if (playbackMuted) { + return true; + } + + pressed &= ~button.mask(); + } // KEY_TYPED carries a character and no key code. default -> { return false; } } - target.setButtons(withoutOpposingDirections(pressed)); + mask = withoutOpposingDirections(pressed); + + // Left to the emulation thread while a movie is involved, which is the whole of the + // difference: it takes this same mask at the next frame boundary instead. + if (!latching) { + target.setButtons(mask); + } return true; } diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java index 264d968..823b48c 100644 --- a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/EmulatorRunnerTests.java @@ -1,12 +1,19 @@ package com.github.dimiro1.mynes.ui; import com.github.dimiro1.mynes.Cart; +import com.github.dimiro1.mynes.Controller; import com.github.dimiro1.mynes.NES; import com.github.dimiro1.mynes.debug.Debugger; +import com.github.dimiro1.mynes.state.Movie; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.function.LongPredicate; @@ -29,6 +36,10 @@ *

* 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 stops; + /** + * Anything that went wrong writing a movie, which happens on the emulation thread and so cannot + * fail a test by throwing. + */ + private ArrayBlockingQueue failures; + + @TempDir + private Path directory; + @BeforeEach void setUp() { nes = new NES(Cart.load(rom(), "runner.nes")); debugger = new Debugger(); debugger.attach(nes); stops = new ArrayBlockingQueue<>(16); + failures = new ArrayBlockingQueue<>(16); runner = new EmulatorRunner(nes, new ScreenComponent(), debugger, REWIND_FRAMES); runner.setStopListener(stops::add); @@ -246,6 +267,125 @@ void aPausedMachineIsNotRewound() throws Exception { assertTrue(nes.getPPU().getFrame() >= played, "a paused machine holds where it is"); } + // ==================================================================================== movies + + /** + * The mask the loop latches is the mask that gets written down, for every frame that finished. + *

+ * 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(4); + var second = new NES(Cart.load(rom(), "runner.nes")); + + // No history, so nothing here depends on the ring: the point is the cursor and the handover. + runner = new EmulatorRunner(second, new ScreenComponent(), new Debugger(), 0); + runner.setPlaybackEndedListener(() -> ended.add(true)); + runner.startPlayback(movie); + runner.start(); + + assertNotNull( + ended.poll(PATIENCE_SECONDS, TimeUnit.SECONDS), + "the movie never finished, or never said so"); + assertTrue(second.getPPU().getFrame() >= movie.frameCount(), + "and every frame of it was played"); + } + + /** + * Stops the recording, waits for the file to land, and reads it back. + *

+ * 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 screenshots) { + long frames, + Report.StoppedBecause stoppedBecause, + List screenshots, + Movie recorded, + Path recordedTo) { } /** @@ -287,23 +362,40 @@ private static Patched patch(final Options options, final byte[] image) throws I } /** - * Plays the schedule. + * Plays the schedule, or the movie. + * + * @param frames how many to run, which is the movie's own length when there is one and nobody + * named a number. + * @param movie the movie to play, or null to walk {@code --input} and {@code --reset-at}. */ - private static Outcome oneShot(final Options options, final Session session) + private static Outcome oneShot( + final Options options, final Session session, final long frames, final Movie movie) throws IOException { var resets = new HashSet<>(options.resetAt()); var screenshots = new ArrayList(); var deadline = System.nanoTime() + options.timeout().toNanos(); var stoppedBecause = Report.StoppedBecause.FRAMES; - for (var frame = 1L; frame <= options.frames(); frame++) { - if (resets.contains(frame)) { - session.reset(); + for (var frame = 1L; frame <= frames; frame++) { + if (movie != null) { + // Counted from the movie's own start rather than from the machine's, which are the + // same number only for a movie that begins at power on. Reset first, then the + // buttons, then the frame: the order a recorder wrote them down in. + if (movie.resetsAt(frame - 1)) { + session.reset(); + } + + session.setButtons(movie.buttonsAt(frame - 1)); + } else { + if (resets.contains(frame)) { + session.reset(); + } + + // Set before the frame is emulated rather than after, so that a one frame press is + // held for the whole of the frame a game might read the pad anywhere in. + session.setButtons(options.input().buttonsAt(frame)); } - // Set before the frame is emulated rather than after, so that a one frame press is - // held for the whole of the frame a game might read the pad anywhere in. - session.setButtons(options.input().buttonsAt(frame)); session.advanceFrame(); if (options.wantsScreenshotAt(frame)) { @@ -313,7 +405,7 @@ private static Outcome oneShot(final Options options, final Session session) if (System.nanoTime() - deadline >= 0) { stoppedBecause = Report.StoppedBecause.TIMEOUT; - logger.log(Level.WARNING, "timed out at frame " + frame + " of " + options.frames()); + logger.log(Level.WARNING, "timed out at frame " + frame + " of " + frames); break; } } @@ -327,7 +419,8 @@ private static Outcome oneShot(final Options options, final Session session) screenshots.sort(Long::compare); - return new Outcome(session.frame(), stoppedBecause, List.copyOf(screenshots)); + return new Outcome( + session.frame(), stoppedBecause, List.copyOf(screenshots), null, null); } /** @@ -340,8 +433,14 @@ private static Outcome interactive(final Options options, final Session session) : new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { var repl = new Repl(session, options, in, System.out, wantsText(options)); - - return new Outcome(repl.run(), Report.StoppedBecause.QUIT, List.of()); + var frames = repl.run(); + + return new Outcome( + frames, + Report.StoppedBecause.QUIT, + List.of(), + repl.recordedMovie(), + repl.recordedPath()); } } diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java index 539543f..785a2d6 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java @@ -25,6 +25,9 @@ * @param patches IPS patches to apply to it, in the order they were named, before it is * read as a cartridge at all. * @param frames how many frames to run, when nothing stops it sooner. + * @param framesSet whether {@code --frames} was actually given. {@code --play} runs the + * movie's own length unless somebody named one, and how long a movie is + * cannot be known while the command line is still being read. * @param timeout how much real time to allow. * @param resetAt frames to press the console's Reset button at the start of. * @param input what to press, and when. @@ -48,6 +51,10 @@ * @param saveState where to write a save state when the run ends, or null. * @param sramIn a battery file to fill the cartridge's RAM from before starting, or null. * @param sramOut where to write that RAM when the run ends, or null. + * @param record where to write a movie of the run, or null. + * @param play a movie to play instead of a schedule, or null. It carries the input, the + * resets, the codes and where the run starts, so it refuses the flags that + * would say those things twice. * @param expectNotBlank the final picture must show more than one colour. * @param expectAudio some sample must not have been silence. * @param expectMotion at least this many frames must have differed from the one before, or -1. @@ -62,6 +69,7 @@ public record Options( Path rom, List patches, long frames, + boolean framesSet, Duration timeout, List resetAt, InputSchedule input, @@ -84,6 +92,8 @@ public record Options( Path saveState, Path sramIn, Path sramOut, + Path record, + Path play, boolean expectNotBlank, boolean expectAudio, long expectMotion, @@ -275,6 +285,29 @@ The second is worth building once (mvn -B package -DskipTests) for anything run --save-state FILE Write a save state when the run ends. Applied after --sram-in, so a state's own copy of the cartridge RAM wins. + Movies, which are sessions rather than snapshots + --record FILE Write a .mnm movie of this run: where it started, one button + mask per finished frame, and the frames Reset was pressed at. + Combines with everything, including --interactive. + 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, or a rewind that went back past the + start of the recording -- puts a save state in the file to + start from, since there is otherwise nothing to say where the + beginning was. + 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. + --play FILE Play one instead of running a schedule. --frames defaults to + the movie's own length; asking for more runs on past the end + with nothing held down, which is how to see what the game does + when the player stops playing. + The movie is the input, so --play refuses --record, --input, + --input-file, --reset-at, --genie, --load-state, --sram-in and + --interactive rather than letting one of them quietly win. + It has to be the same cartridge and the same region; anything + else exits 2. run.replay in the report says what was played. + Expectations. Each one that fails makes the run exit 4; the report says which. Anything more particular than these belongs in jq over the report. --expect-not-blank The final picture must show more than one colour. @@ -313,6 +346,7 @@ public static Options parse(final String[] args) { Path rom = null; var patches = new ArrayList(); var frames = DEFAULT_FRAMES; + var framesSet = false; var timeout = DEFAULT_TIMEOUT; var resetAt = new ArrayList(); var inputSpecs = new ArrayList(); @@ -335,6 +369,8 @@ public static Options parse(final String[] args) { Path saveState = null; Path sramIn = null; Path sramOut = null; + Path record = null; + Path play = null; var expectNotBlank = false; var expectAudio = false; var expectMotion = -1L; @@ -352,7 +388,10 @@ public static Options parse(final String[] args) { case "--list-palettes" -> listPalettes = true; case "--rom" -> rom = Path.of(value(args, ++i, flag)); case "--patch" -> patches.add(Path.of(value(args, ++i, flag))); - case "--frames" -> frames = positive(value(args, ++i, flag), flag); + case "--frames" -> { + frames = positive(value(args, ++i, flag), flag); + framesSet = true; + } case "--timeout" -> timeout = Duration.ofSeconds( positive(value(args, ++i, flag), flag)); case "--reset-at" -> resetAt.add(positive(value(args, ++i, flag), flag)); @@ -379,6 +418,8 @@ public static Options parse(final String[] args) { case "--save-state" -> saveState = Path.of(value(args, ++i, flag)); case "--sram-in" -> sramIn = Path.of(value(args, ++i, flag)); case "--sram-out" -> sramOut = Path.of(value(args, ++i, flag)); + case "--record" -> record = Path.of(value(args, ++i, flag)); + case "--play" -> play = Path.of(value(args, ++i, flag)); case "--expect-not-blank" -> expectNotBlank = true; case "--expect-audio" -> expectAudio = true; case "--expect-motion" -> expectMotion = positive(value(args, ++i, flag), flag); @@ -400,6 +441,29 @@ public static Options parse(final String[] args) { throw new UsageException("--rom is required. --help says what else there is."); } + if (play != null) { + // Each of these is a second answer to a question the movie has already answered, and a + // run that quietly took one of them would not be the recorded session at all. Refused + // one at a time rather than as a list, so the message names the flag that was typed. + refuseWithPlay(record != null, "--record", + "a movie is not something to re-record; play it and record the result some" + + " other way if that is really the intention"); + refuseWithPlay(!inputSpecs.isEmpty(), "--input", + "the movie is the input"); + refuseWithPlay(!resetAt.isEmpty(), "--reset-at", + "the movie carries the frames Reset was pressed at"); + refuseWithPlay(!genie.isEmpty(), "--genie", + "the movie carries the codes it was recorded with, and putting others in would" + + " be a different run"); + refuseWithPlay(loadState != null, "--load-state", + "the movie says where it starts, at power on or from a state inside it"); + refuseWithPlay(sramIn != null, "--sram-in", + "a movie that needed the battery filled was recorded from a state that already" + + " has it"); + refuseWithPlay(interactive, "--interactive", + "a replay is a run of a schedule that is already written down"); + } + var report = STDOUT.equals(reportPath) ? null : reportPath == null ? outDir.resolve(REPORT_NAME) : Path.of(reportPath); @@ -407,6 +471,7 @@ public static Options parse(final String[] args) { rom, List.copyOf(patches), frames, + framesSet, timeout, List.copyOf(resetAt), InputSchedule.parse(inputSpecs, pressFrames), @@ -429,6 +494,8 @@ public static Options parse(final String[] args) { saveState, sramIn, sramOut, + record, + play, expectNotBlank, expectAudio, expectMotion, @@ -494,6 +561,21 @@ public boolean wantsScreenshotAt(final long frame) { || (screenshotEvery > 0 && frame % screenshotEvery == 0); } + /** + * Refuses one of the flags {@code --play} replaces, saying which and why. + *

+ * 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 screenshots, List dumps, List expectations, + Movie recorded, + Path recordedTo, + Movie replayed, int exitCode) { } @@ -169,11 +176,28 @@ public static String write( // counter no longer describes, so its frameChanges and its sound are not the straight run's. // Always present and 0 when nobody rewound, so two reports still compare key for key. var state = run.putObject("state"); - state.put("startedFromPowerOn", options.loadState() == null); + + // A replayed run started wherever the movie says it did, which is a state inside the movie + // whenever the movie is anchored -- so a replay of an anchored take is no more comparable + // with a power-on run than a --load-state one is. + state.put("startedFromPowerOn", + options.loadState() == null + && (outcome.replayed() == null || !outcome.replayed().anchored())); put(state, "loadedFrom", options.loadState()); put(state, "savedTo", options.saveState()); state.put("framesRewound", session.framesRewound()); + // The fifth and sixth things that decide whether two runs are comparable, and the only two + // that describe a whole session rather than a moment in one. Always present, with explicit + // nulls, so two reports line up key for key whether either run touched a movie. + var recorded = run.putObject("record"); + put(recorded, "savedTo", outcome.recordedTo()); + describe(recorded, outcome.recorded()); + + var replayed = run.putObject("replay"); + put(replayed, "playedFrom", options.play()); + describe(replayed, outcome.replayed()); + var cartridge = report.putObject("cart"); cartridge.put("file", cart.filename()); cartridge.put("name", Path.of(cart.filename()).getFileName().toString()); @@ -287,7 +311,7 @@ public static String write( var input = report.putObject("input"); input.put("pressFrames", options.pressFrames()); - input.put("framesWithInput", framesWithInput(options, outcome.frames())); + input.put("framesWithInput", framesWithInput(options, outcome)); var resetAt = input.putArray("resetAt"); for (var frame : options.resetAt()) { @@ -354,12 +378,20 @@ public static String write( /** * How many frames of the run had a button held down. A schedule that turns out to press nothing * is the commonest reason a headless run of a real cartridge shows a title screen and no game. + *

+ * 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