From 22808f070e089a7b25fe499e223ce62da76542df Mon Sep 17 00:00:00 2001 From: dimiro1 Date: Fri, 21 Aug 2026 23:38:53 +0200 Subject: [PATCH] Record a session and play it back, from a Machine menu or --record A movie is not a video and holds no picture of the machine at all. The console is deterministic -- nothing in it reads a clock or a random number -- so a session is where it started, one button mask per finished frame, and the frames Reset was pressed at, and playing that back reproduces the run byte for byte rather than approximately. Nine hundred frames of Super Mario Bros. comes to 119 bytes, and the save state at the end of a replay is `cmp`-identical to the one at the end of the recording. The headline is that a rewind is 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 a replay never re-enacts the revert. That is exact rather than a convenient approximation, and it composes out of something already proved: `RewindTests.rewindingGoesBackToTheFrameItLeft` shows a rewound machine *is* the machine that never went forward, so there is nothing lost by truncating to match. A rewind that goes back past the recording's own start re-anchors instead, since the log no longer describes where the machine is; a loaded save state re-anchors for the same reason. One rule for every jump. Recording from power on carries no state at all, which is what makes a movie of a whole playthrough something to hand to anybody with the same ROM. Anything else puts a save state in the file to start from, because there is otherwise nothing to say where the beginning was -- and that includes `--sram-in`, since a movie has no way to carry a battery either. The anchor is a whole `SaveState` file nested inside the gzipped 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. The Game Genie codes ride inside the movie and are put back on replay, and they have to. A cheated cartridge is byte for byte an honest one, so `cart.sha256` cannot tell the two apart and nothing else in the file could say so -- the same argument `--genie` already makes about save states, turned into a chunk. Both front ends refuse to change the codes while a recording is running, because a file whose header names one set and whose frames were played against another cannot be replayed and would not admit it. `.mnm` follows `SaveState`'s discipline exactly: big endian, a fixed header outside the compression so a file can be labelled or refused without inflating it, length-prefixed chunks a later version may add to and this one steps over, a version bumped only when something already there changes meaning, and everything the file can be wrong about checked in `Movie.read` before there is a machine to touch. `Movie` and `MovieRecorder` are deliberately not reachable from `NES`, for the reason `Rewind` is not: `SaveStateCompletenessTests` walks everything the console can reach and scrambles every array it finds, so a growing log hanging off a chip would be shredded. They belong to whoever is driving the machine, which is also the honest place for them. The desktop has an asymmetry to answer. Key events reach the controller mid-frame on the event dispatch thread, and a press that landed half way through a frame would be written down as belonging to a frame it was only half of. So while a movie is being recorded or played the pad is latched exactly once a frame on the emulation thread, guarded on a frame boundary so a frame resumed after a breakpoint is not re-latched in flight. When neither is happening the immediate path is untouched, because that is the one a player feels. A replay mutes the keyboard entirely -- a bumped key must not leak into somebody else's session -- except for rewind, which is how you take the game back. `MovieRecorder.rewound` takes frames rather than rewind steps, and the difference is not cosmetic: `Rewind.rewind` answers in states, the window keeps one state every *other* frame, and passing the wrong one of the two leaves the movie holding twice the frames the machine actually went back over. `EmulatorRunnerTests.rewindingWhileRecordingDropsTheFramesItTookBack` pins the invariant that catches it -- a movie holds exactly the frames between its anchor and where the machine stands, however many were played twice on the way. `run.record` and `run.replay` join `run.state`, `run.region`, `run.hacks` and `run.genie` in what has to be checked before diffing two reports, always present with explicit nulls so two documents line up key for key. Two honesty fixes came with them. `startedFromPowerOn` is false for a replay of an anchored take, which is no more a power-on run than a `--load-state` one is. And `framesWithInput` walks the movie when replaying rather than the schedule `--play` refused, since answering 0 for a run that pressed something on every frame would be the number in that document most likely to be believed. `--play` refuses `--record`, `--input`, `--input-file`, `--reset-at`, `--genie`, `--load-state`, `--sram-in` and `--interactive` one at a time, naming the flag that was typed. Each 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 -- and would look exactly like one that worked. It defaults `--frames` to the movie's own length, since running past the end with nothing held is how to see what a game does when the player stops playing. No new dependencies, no chip gains a field, and `VERSION` stays 1 everywhere. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 48 +- README.md | 31 + .../com/github/dimiro1/mynes/state/Movie.java | 707 ++++++++++++++++++ .../dimiro1/mynes/state/MovieException.java | 20 + .../dimiro1/mynes/state/MovieRecorder.java | 266 +++++++ .../github/dimiro1/mynes/state/SaveState.java | 49 +- .../dimiro1/mynes/state/package-info.java | 8 +- .../dimiro1/mynes/state/MovieTests.java | 638 ++++++++++++++++ .../dimiro1/mynes/ui/EmulatorRunner.java | 273 +++++++ .../github/dimiro1/mynes/ui/GameUIFrame.java | 335 ++++++++- .../dimiro1/mynes/ui/input/KeyboardInput.java | 99 ++- .../dimiro1/mynes/ui/EmulatorRunnerTests.java | 140 ++++ .../dimiro1/mynes/headless/Headless.java | 139 +++- .../dimiro1/mynes/headless/Options.java | 84 ++- .../github/dimiro1/mynes/headless/Repl.java | 126 ++++ .../github/dimiro1/mynes/headless/Report.java | 59 +- .../dimiro1/mynes/headless/Session.java | 132 ++++ .../mynes/headless/GameGenieRunTests.java | 28 + .../mynes/headless/HeadlessRunTests.java | 243 ++++++ .../dimiro1/mynes/headless/OptionsTests.java | 85 +++ .../dimiro1/mynes/headless/ReplTests.java | 113 +++ 21 files changed, 3571 insertions(+), 52 deletions(-) create mode 100644 mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java create mode 100644 mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieException.java create mode 100644 mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java create mode 100644 mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java 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