Skip to content

Record a session and play it back, from a Machine menu or --record - #40

Merged
dimiro1 merged 1 commit into
masterfrom
dimiro1/session-recording-design
Aug 21, 2026
Merged

Record a session and play it back, from a Machine menu or --record#40
dimiro1 merged 1 commit into
masterfrom
dimiro1/session-recording-design

Conversation

@dimiro1

@dimiro1 dimiro1 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

A movie holds no picture of the machine at all: because the console is deterministic, a session is where it started, one button mask per finished frame, and the frames Reset was pressed at — 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. 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; a rewind past the recording's own start, or a loaded save state, re-anchors instead. Recording from power on carries no state at all, anything else embeds one to begin from, and the Game Genie codes ride inside the file — they have to, since a cheated cartridge is byte for byte an honest one and cart.sha256 cannot tell them apart. Both front ends are wired: --record/--play and record start/record stop in the REPL, Machine > Record Movie… / Play Movie… in the window, where the pad moves to a once-a-frame latch on the emulation thread so a mid-frame press cannot be written down as belonging to a frame it was only half of. New MovieTests, EmulatorRunnerTests and headless round-trip tests cover it, run.record/run.replay join the report's comparability checklist, and there are no new dependencies and no new fields on any chip.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@dimiro1
dimiro1 merged commit f2e80fd into master Aug 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant