A demo recorder: --record-pad writes what --pad reads

One byte a frame, in exactly the format the player takes, so a recording
needs no conversion and there is no second format to keep in step. That
symmetry is the feature, and it makes the strongest form of the claim
testable: a recording is made OF a playback, and the bytes coming out have
to be the bytes that went in.

It exists because some inputs cannot sensibly be written by hand. Flying a
lander from one base to another is a few hundred frames of steering that
has to arrive somewhere eight cells wide, and several attempts at
authoring one got within two columns and no closer. That is a piloting
exercise rather than a test. Playing it once and keeping what happened is
the answer.

A BYTE FOR EVERY FRAME, written inside the loop that advances the
recordings rather than after it, so a machine that jumped several frames
at once still writes one for each. A recording is a timeline: one that
skipped the frames nobody looked at would play back faster than it was
flown.

What is recorded is what the DEVICE WOULD REPORT, not the live state - a
recording of a playback that wrote the live state would be a file of
noughts. And it is flushed as it goes, because a recording is usually
stopped by whoever is playing rather than by the program ending, and a
demo lost to a buffer is a demo flown twice.

Tests/replay.sh is where this and whatever follows it are checked. Twelve
scripts now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-09-03 12:38:43 -04:00
co-authored by Claude Opus 5
parent caf5e1f99d
commit a163c670d0
8 changed files with 186 additions and 3 deletions
+9
View File
@@ -238,6 +238,15 @@ uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *pro
padFromFile(n, pad);
}
for (int n = 0; n < options->padRecordCount; n++) {
FILE *pad = fopen(options->padRecord[n], "wb");
if (pad == NULL) {
fprintf(stderr, "Error: Couldn't write pad file: %s\n", options->padRecord[n]);
return 0;
}
padRecordTo(n, pad);
}
if (options->keyboard != NULL) {
keyboardFile = fopen(options->keyboard, "rb");
if (keyboardFile == NULL) {
+31 -1
View File
@@ -10,6 +10,7 @@ static uint8_t held[PAD_COUNT];
static FILE *recorded[PAD_COUNT];
static uint8_t live[PAD_COUNT];
static int connected[PAD_COUNT];
static FILE *recording[PAD_COUNT];
// ---- The frame the recordings advance on ----
//
@@ -36,6 +37,19 @@ void padReset(void) {
started = 0;
}
// What the device would report for this pad, which is what a recording has to hold: a
// recording of a playback that wrote the LIVE state would be a file of noughts.
static uint8_t effective(int which) {
return (recorded[which] != NULL) ? held[which] : live[which];
}
void padRecordTo(int which, FILE *file) {
if (which < 0 || which >= PAD_COUNT) {
return;
}
recording[which] = file;
}
void padFromFile(int which, FILE *file) {
if (which < 0 || which >= PAD_COUNT) {
return;
@@ -72,6 +86,22 @@ void padTick(unsigned long now) {
// off the edge of the world long after the test meant to stop.
held[n] = (byte == EOF) ? 0 : (uint8_t)byte;
}
// ---- And a byte written for every frame that went by ----
//
// Inside the loop rather than after it, so a machine that jumped several frames at
// once still writes one byte for each of them. A recording is a TIMELINE, and one
// that skipped the frames nobody was looking at would play back faster than it was
// flown.
//
// Flushed as it goes, because a recording is usually stopped by whoever is playing
// rather than by the program ending, and a demo lost to a buffer would be a demo
// flown twice.
for (int n = 0; n < PAD_COUNT; n++) {
if (recording[n] != NULL) {
fputc(effective(n), recording[n]);
fflush(recording[n]);
}
}
}
}
@@ -96,5 +126,5 @@ uint8_t padRead(uint8_t port) {
}
// A recording wins over a live pad, so a test is not at the mercy of whatever somebody
// is leaning on while it runs.
return (recorded[which] != NULL) ? held[which] : live[which];
return effective(which);
}
+12
View File
@@ -79,6 +79,18 @@ void padFromFile(int which, FILE *file);
// underneath was reporting its buttons perfectly.
void padSet(int which, int connected, uint8_t held);
// ---- Writing one down ----
//
// A demo recorder. What it writes is EXACTLY WHAT --pad READS, one byte a frame, so playing a
// recording back needs no conversion and no second format - and the round trip is a property
// worth testing: play a file while recording it and the same bytes come out.
//
// It exists because some inputs cannot sensibly be written by hand. Flying a lander from one
// base to another is a few hundred frames of steering that has to arrive somewhere four cells
// wide, and hand-authoring one is a piloting exercise rather than a test. Playing it once and
// keeping what happened is the whole answer.
void padRecordTo(int which, FILE *file);
void padTick(unsigned long now);
uint8_t padRead(uint8_t port);
+11 -1
View File
@@ -38,6 +38,10 @@ void printHelp(const char *programName) {
printf(" again for the next pad. A byte is the buttons held:\n");
printf(" 1 right, 2 left, 4 down, 8 up, 16 A, 32 B, 64 start,\n");
printf(" 128 select.\n");
printf(" -Y, --record-pad FILE\n");
printf(" Write what a controller held, one byte a frame, in the\n");
printf(" format --pad reads. Given again for the next pad. Play a\n");
printf(" thing once and keep what happened.\n");
printf(" -N, --sound FILE Save every sample the machine made, as raw signed 16 bit\n");
printf(" at 48kHz. What --screen is for a picture: the only way to\n");
printf(" check a sound on a machine with no speaker.\n");
@@ -57,6 +61,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
{"keyboard", required_argument, 0, 'K'},
{"sound", required_argument, 0, 'N'},
{"pad", required_argument, 0, 'P'},
{"record-pad", required_argument, 0, 'Y'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0 }
};
@@ -76,7 +81,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
*options = (EmulatorOptions){0};
// Parse options
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:N:R:P:", long_options, &option_index)) != -1) {
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:N:R:P:Y:", long_options, &option_index)) != -1) {
switch (opt) {
case 'd':
options->debug = 1;
@@ -136,6 +141,11 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
case 'N':
options->sound = optarg;
break;
case 'Y':
if (options->padRecordCount < PAD_DRIVE_COUNT) {
options->padRecord[options->padRecordCount++] = optarg;
}
break;
case 'P':
// Fills the pads in turn, the same way --disk fills the drives, so the first
// one named is pad nought and the machine has as many as were asked for.
+3
View File
@@ -47,6 +47,9 @@ typedef struct {
// line editing was in when it broke twice in two days.
const char *pads[PAD_DRIVE_COUNT];
int padCount; // --pad given more than once fills them in turn, like --disk.
// Where to write what each pad held, one byte a frame, in the format --pad reads.
const char *padRecord[PAD_DRIVE_COUNT];
int padRecordCount;
} EmulatorOptions;
uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options);