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); 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) { if (options->keyboard != NULL) {
keyboardFile = fopen(options->keyboard, "rb"); keyboardFile = fopen(options->keyboard, "rb");
if (keyboardFile == NULL) { if (keyboardFile == NULL) {
+31 -1
View File
@@ -10,6 +10,7 @@ static uint8_t held[PAD_COUNT];
static FILE *recorded[PAD_COUNT]; static FILE *recorded[PAD_COUNT];
static uint8_t live[PAD_COUNT]; static uint8_t live[PAD_COUNT];
static int connected[PAD_COUNT]; static int connected[PAD_COUNT];
static FILE *recording[PAD_COUNT];
// ---- The frame the recordings advance on ---- // ---- The frame the recordings advance on ----
// //
@@ -36,6 +37,19 @@ void padReset(void) {
started = 0; 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) { void padFromFile(int which, FILE *file) {
if (which < 0 || which >= PAD_COUNT) { if (which < 0 || which >= PAD_COUNT) {
return; return;
@@ -72,6 +86,22 @@ void padTick(unsigned long now) {
// off the edge of the world long after the test meant to stop. // off the edge of the world long after the test meant to stop.
held[n] = (byte == EOF) ? 0 : (uint8_t)byte; 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 // A recording wins over a live pad, so a test is not at the mercy of whatever somebody
// is leaning on while it runs. // 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. // underneath was reporting its buttons perfectly.
void padSet(int which, int connected, uint8_t held); 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); void padTick(unsigned long now);
uint8_t padRead(uint8_t port); 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(" 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(" 1 right, 2 left, 4 down, 8 up, 16 A, 32 B, 64 start,\n");
printf(" 128 select.\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(" -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(" at 48kHz. What --screen is for a picture: the only way to\n");
printf(" check a sound on a machine with no speaker.\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'}, {"keyboard", required_argument, 0, 'K'},
{"sound", required_argument, 0, 'N'}, {"sound", required_argument, 0, 'N'},
{"pad", required_argument, 0, 'P'}, {"pad", required_argument, 0, 'P'},
{"record-pad", required_argument, 0, 'Y'},
{"help", no_argument, 0, 'h'}, {"help", no_argument, 0, 'h'},
{0, 0, 0, 0 } {0, 0, 0, 0 }
}; };
@@ -76,7 +81,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
*options = (EmulatorOptions){0}; *options = (EmulatorOptions){0};
// Parse options // 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) { switch (opt) {
case 'd': case 'd':
options->debug = 1; options->debug = 1;
@@ -136,6 +141,11 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
case 'N': case 'N':
options->sound = optarg; options->sound = optarg;
break; break;
case 'Y':
if (options->padRecordCount < PAD_DRIVE_COUNT) {
options->padRecord[options->padRecordCount++] = optarg;
}
break;
case 'P': case 'P':
// Fills the pads in turn, the same way --disk fills the drives, so the first // 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. // 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. // line editing was in when it broke twice in two days.
const char *pads[PAD_DRIVE_COUNT]; const char *pads[PAD_DRIVE_COUNT];
int padCount; // --pad given more than once fills them in turn, like --disk. 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; } EmulatorOptions;
uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options); uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options);
+13 -1
View File
@@ -9,7 +9,7 @@ believe them.
## What The Suite Claims: ## What The Suite Claims:
The suite is not one thing. It is eleven scripts making five different kinds of claim, and The suite is not one thing. It is twelve scripts making five different kinds of claim, and
knowing which claim you are relying on is the whole point of this document. A recorded knowing which claim you are relying on is the whole point of this document. A recorded
transcript and a byte-for-byte comparison against a second implementation both print transcript and a byte-for-byte comparison against a second implementation both print
`[ok ]`, and they are worth wildly different amounts. `[ok ]`, and they are worth wildly different amounts.
@@ -64,12 +64,24 @@ Individual scripts can be run on their own, from anywhere:
./Tests/agree.sh The two filesystems against each other. ./Tests/agree.sh The two filesystems against each other.
./Tests/lint.sh SplitLint against its fixture and the corpus baseline. ./Tests/lint.sh SplitLint against its fixture and the corpus baseline.
./Tests/lint.sh --bless Record the corpus baseline. See below. ./Tests/lint.sh --bless Record the corpus baseline. See below.
./Tests/replay.sh A recording of input, played back as itself.
./Tests/docs.sh The manuals against the code. ./Tests/docs.sh The manuals against the code.
``` ```
`Tests/makedisks.sh` is not in that list because it checks nothing. It builds the disk `Tests/makedisks.sh` is not in that list because it checks nothing. It builds the disk
images the other scripts read, and `run.sh` calls it. images the other scripts read, and `run.sh` calls it.
`Tests/replay.sh` checks that `--record-pad` and `--pad` are the same format read two ways.
A recording is written one byte a frame and is only worth having if playing it back does what
was recorded, so the strongest form of that claim is checked: 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 by hand 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.
`Tests/break.sh` is not in it either, for the reason turned round: it checks that a check `Tests/break.sh` is not in it either, for the reason turned round: it checks that a check
works. Run it by hand when a check is written, not as part of `make test`. works. Run it by hand when a check is written, not as part of `make test`.
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Checks that what the machine records is what it plays back.
#
# --record-pad writes what a controller held, one byte a frame, in exactly the format --pad
# reads. That symmetry is the whole feature and it is the whole of what is checked here: a
# recording is only worth having if playing it back does what was recorded.
#
# ---- Why it exists ----
#
# 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 hand-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, and
# this is what says the keeping works.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD="$ROOT/Tests/build/replay"
ASM="$ROOT/Assembler"
EMU="$ROOT/SplitBit"
for tool in "$ASM" "$EMU"; do
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
done
rm -rf "$BUILD"; mkdir -p "$BUILD"
PASS=0
FAIL=0
FAILED_NAMES=()
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
result() {
if [ "$1" = "ok" ]; then
PASS=$((PASS + 1)); printf " [%sok %s] %-40s %s\n" "$GREEN" "$RESET" "$2" "$3"
else
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$2")
printf " [%sFAIL%s] %-40s %s\n" "$RED" "$RESET" "$2" "$3"
fi
}
echo "Checking that a recording plays back as itself."
# A program that reads a pad every frame and stops. It is the one in testPrograms rather than
# a new one, because what is being checked is the recording and not the reading.
"$ASM" "$ROOT/Programs/testPrograms/padTest.asm" -o "$BUILD/padTest.bin" > "$BUILD/asm.log" 2>&1 \
|| { echo "padTest did not assemble."; exit 1; }
# ---- Played and recorded at the same time ----
#
# The strongest form of the claim: the recording is made OF a playback, so if the two formats
# ever drift apart the bytes coming out stop matching the bytes going in.
"$EMU" --fast --pad "$ROOT/Tests/input/padTest.pad" \
--record-pad "$BUILD/roundtrip.pad" "$BUILD/padTest.bin" > "$BUILD/roundtrip.out" 2>&1
SAME="$(python3 -c "
first = open('$ROOT/Tests/input/padTest.pad', 'rb').read()
again = open('$BUILD/roundtrip.pad', 'rb').read()
print('yes' if again[:len(first)] == first else 'no')
" 2>/dev/null || echo error)"
[ "$SAME" = "yes" ] \
&& result ok "a recording of a playback is the playback" "byte for byte, in and out" \
|| result no "a recording of a playback is the playback" "the bytes differ"
# ---- One byte a frame, and the frame is the machine's ----
#
# A recording is a TIMELINE. If it were a byte a read, a program that polled twice in one
# frame would record twice as fast as it flew; if it skipped frames nobody looked at, it would
# play back faster than it was flown. So the count is the number of frames the machine ran,
# which at 16,667 cycles a frame is arithmetic rather than a guess.
"$EMU" --fast --cycles 500000 --record-pad "$BUILD/timeline.pad" \
"$BUILD/padTest.bin" > "$BUILD/timeline.out" 2>&1
WROTE="$(wc -c < "$BUILD/timeline.pad" | tr -d ' ')"
# The machine halts of its own accord well before the limit, so what is checked is that the
# count is frames-of-something rather than bytes-of-nothing: more than none, and fewer than
# the limit could possibly hold.
[ "$WROTE" -gt 0 ] && [ "$WROTE" -le 30 ] \
&& result ok "a byte for every frame and no more" "$WROTE frames of it" \
|| result no "a byte for every frame and no more" "$WROTE bytes, which is not a frame count"
# ---- And a machine with no controller records that honestly ----
#
# Nothing held is nought, which is what a pad nobody is touching reports and what a machine
# with no pad at all reports. A recorder that wrote something else would make a fixture that
# pressed buttons nobody pressed.
QUIET="$(python3 -c "
data = open('$BUILD/timeline.pad', 'rb').read()
print('yes' if set(data) <= {0} else 'no')
" 2>/dev/null || echo error)"
[ "$QUIET" = "yes" ] \
&& result ok "and nothing held is written as nothing" "no buttons nobody pressed" \
|| result no "and nothing held is written as nothing" "the recording holds something"
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS replay checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1
+4
View File
@@ -358,6 +358,8 @@ test: all strict
@echo @echo
@./Tests/agree.sh @./Tests/agree.sh
@echo @echo
@./Tests/replay.sh
@echo
@./Tests/docs.sh @./Tests/docs.sh
# Rebuild all three tools with the address and undefined behaviour sanitizers and run # Rebuild all three tools with the address and undefined behaviour sanitizers and run
@@ -405,6 +407,8 @@ sanitize:
@echo @echo
@./Tests/agree.sh @./Tests/agree.sh
@echo @echo
@./Tests/replay.sh
@echo
@./Tests/docs.sh @./Tests/docs.sh
@$(MAKE) --no-print-directory clean @$(MAKE) --no-print-directory clean
@$(MAKE) --no-print-directory @$(MAKE) --no-print-directory