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:
co-authored by
Claude Opus 5
parent
caf5e1f99d
commit
a163c670d0
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
Executable
+103
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user