Split the machine from its front end, and add Voyager
The Segan Voyager is the same SplitBit with a screen and a speaker instead of a terminal, and this is the rung that makes there be two of them at all. Everything that is actually the machine - the CPU, the controller, the devices, the run loop, the reporting - moves to machine.c, and each front end brings one file of its own. emulator.c is now sixty lines of argument handling and a three line loop. The machine runs in SLICES rather than to completion, because that is the cut a window needs: run a slice, present a frame, run another. A terminal runs slices until the machine stops. Both loops are three lines, which is why the cut is there rather than anywhere else. At this stage Voyager's window is empty. There is no video device yet and inventing a temporary way to draw would mean building something to throw away. PLAIN MAKE STILL WORKS WITH NO GRAPHICS LIBRARY. Raylib is probed by compiling and linking against it rather than by looking for a file, because a header with no library behind it passes a file check and then fails at link time. Where it is missing, make says so once and builds everything else - the machine, the assembler, the disk tool, the linter and the whole suite. A project about a small understandable CPU should not need OpenGL to run its tests. That nearly broke here: make strict globs Source/Emulator/*.c, so it would have tried to compile voyager.c and failed on precisely the machines the split exists to support, and this machine has Raylib so nothing would have caught it. Tests/voyager.sh runs the WHOLE MANIFEST through Voyager and holds it to the recorded results SplitBit is held to. Not that the two look alike: that one satisfies every recording the other does, byte for byte, exit status included. It reuses run.sh, which now takes the machine from SPLITBIT_EMULATOR, rather than keeping a second copy of the runner that would drift. Voyager not being built is not a failure - it says so and passes. Verified both ways. Made Voyager print one extra line, and 114 of 165 failed: exactly the tests that run the emulator, with the 51 assemble-only and xfail cases correctly untouched. Removed the binary, and the script skipped. Built with HAVE_RAYLIB=no, and everything else still built and checked clean. --headless is taken out of the arguments in voyager.c rather than in the shared parser, which should not learn about a window only one binary has. It exists so the suite can run this binary at all: a front end that could only be exercised by a person looking at it would be a front end nothing checks. loadFile takes a const char * now, which it always should have. 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
4c3eac8d9c
commit
e3ef25e3b3
@@ -8,6 +8,7 @@
|
|||||||
/SplitBit
|
/SplitBit
|
||||||
/SplitDisk
|
/SplitDisk
|
||||||
/SplitLint
|
/SplitLint
|
||||||
|
/Voyager
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/claudeResume.sh
|
/claudeResume.sh
|
||||||
/codexResume.sh
|
/codexResume.sh
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ wrote Asm.sbx: program 7533, data 4099, labels 555
|
|||||||
|
|
||||||
| Directory | What it holds |
|
| Directory | What it holds |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [`Source/Emulator`](Source/Emulator) | The machine: CPU, memory controller, devices, console, disk |
|
| [`Source/Emulator`](Source/Emulator) | The machine: CPU, memory controller, devices, console, disk - and the two front ends that present it |
|
||||||
| [`Source/Assembler`](Source/Assembler) | The assembler that runs on a host |
|
| [`Source/Assembler`](Source/Assembler) | The assembler that runs on a host |
|
||||||
| [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem |
|
| [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem |
|
||||||
| [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms |
|
| [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms |
|
||||||
@@ -31,6 +31,22 @@ wrote Asm.sbx: program 7533, data 4099, labels 555
|
|||||||
| [`Programs/testPrograms`](Programs/testPrograms) | What the test suite drives |
|
| [`Programs/testPrograms`](Programs/testPrograms) | What the test suite drives |
|
||||||
| [`Tests`](Tests) | The suite: the manifest, the recorded output, and the scripts that check it |
|
| [`Tests`](Tests) | The suite: the manifest, the recorded output, and the scripts that check it |
|
||||||
|
|
||||||
|
## Two Front Ends:
|
||||||
|
|
||||||
|
`make` builds **SplitBit**, which is the machine with a terminal attached, and where Raylib
|
||||||
|
is installed it also builds **Voyager**, which is the same machine with a screen and a
|
||||||
|
speaker. Everything that is actually the machine - every instruction, every device, every
|
||||||
|
cycle - is shared between them, and each brings one file of its own: a terminal or a window.
|
||||||
|
|
||||||
|
Voyager is deliberately not required. The machine, the assembler, the disk tool, the linter
|
||||||
|
and the whole test suite build and run on a host with no graphics library at all, because a
|
||||||
|
project about a small understandable CPU should not need OpenGL to run its tests. Where
|
||||||
|
Raylib is missing, `make` says so once and builds everything else.
|
||||||
|
|
||||||
|
The suite holds the two to being the same machine rather than taking it on trust: it runs
|
||||||
|
the entire manifest through Voyager as well, with `--headless`, and requires it to satisfy
|
||||||
|
every recorded result byte for byte.
|
||||||
|
|
||||||
## The Machine:
|
## The Machine:
|
||||||
|
|
||||||
- **Harvard architecture.** Two 64K memories, one for instructions and one for data. An instruction can only read the second, which is why strings live there and why the memory controller exists.
|
- **Harvard architecture.** Two 64K memories, one for instructions and one for data. An instruction can only read the second, which is why strings live there and why the memory controller exists.
|
||||||
@@ -512,7 +528,7 @@ $(BUILD)/%.bin: %.asm
|
|||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
Builds the four tools, checks they build clean under strict ISO C, and runs seven scripts.
|
Builds the four tools, checks they build clean under strict ISO C, and runs nine scripts.
|
||||||
`Tests/run.sh` assembles and runs every program in `Programs/` and compares the results
|
`Tests/run.sh` assembles and runs every program in `Programs/` and compares the results
|
||||||
against recorded output; six more ask the questions a recorded file cannot answer. Between
|
against recorded output; six more ask the questions a recorded file cannot answer. Between
|
||||||
them they check the two assemblers against each other byte for byte, the two SBFS
|
them they check the two assemblers against each other byte for byte, the two SBFS
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ uint8_t loadROM(const unsigned char *bytes, unsigned long length,
|
|||||||
return failed;
|
return failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t loadFile(char *path, uint8_t *Program, uint8_t *Data) {
|
uint8_t loadFile(const char *path, uint8_t *Program, uint8_t *Data) {
|
||||||
FILE *file = fopen(path, "rb");
|
FILE *file = fopen(path, "rb");
|
||||||
if (file == NULL) {
|
if (file == NULL) {
|
||||||
fprintf(stderr, "Error: Couldn't open file: %s\n", path);
|
fprintf(stderr, "Error: Couldn't open file: %s\n", path);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
uint8_t loadFile(char *path, uint8_t *Program, uint8_t *Data);
|
uint8_t loadFile(const char *path, uint8_t *Program, uint8_t *Data);
|
||||||
|
|
||||||
// The same, from bytes the emulator carries rather than a file it opens. See loadROM.
|
// The same, from bytes the emulator carries rather than a file it opens. See loadROM.
|
||||||
uint8_t loadROM(const unsigned char *bytes, unsigned long length,
|
uint8_t loadROM(const unsigned char *bytes, unsigned long length,
|
||||||
|
|||||||
+26
-218
@@ -4,87 +4,23 @@
|
|||||||
// Small 8-Bit Harvard Architecture CPU
|
// Small 8-Bit Harvard Architecture CPU
|
||||||
// Written by Anachronaut
|
// Written by Anachronaut
|
||||||
// 10/15/2024
|
// 10/15/2024
|
||||||
|
|
||||||
#include "rom.h"
|
|
||||||
#include "bootstrap.h"
|
|
||||||
#include "../Assembler/assembly.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include "cpu.h"
|
|
||||||
#include "controller.h"
|
|
||||||
#include "io.h"
|
|
||||||
#include "utility.h"
|
|
||||||
#include <string.h>
|
|
||||||
#include <getopt.h>
|
|
||||||
#include <time.h>
|
|
||||||
|
|
||||||
// nanoseconds per second
|
|
||||||
#define NS_PER_SEC 1000000000LL
|
|
||||||
#define CYCLE_RATE 1000000
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
long long cycles_per_sec; // e.g. 1000000 for 1 MHz
|
|
||||||
long long accumulator_ns; // unspent nanoseconds
|
|
||||||
struct timespec prev;
|
|
||||||
} CycleTimer;
|
|
||||||
|
|
||||||
static inline long long timespec_diff_ns(struct timespec a, struct timespec b) {
|
|
||||||
return (a.tv_sec - b.tv_sec) * NS_PER_SEC + (a.tv_nsec - b.tv_nsec);
|
|
||||||
}
|
|
||||||
|
|
||||||
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec) {
|
|
||||||
t->cycles_per_sec = cycles_per_sec;
|
|
||||||
t->accumulator_ns = 0;
|
|
||||||
clock_gettime(CLOCK_MONOTONIC, &t->prev);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call once per host frame. Returns how many SplitBit cycles to execute.
|
|
||||||
int cycle_timer_tick(CycleTimer *t) {
|
|
||||||
struct timespec now;
|
|
||||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
|
||||||
|
|
||||||
long long elapsed = timespec_diff_ns(now, t->prev);
|
|
||||||
t->prev = now;
|
|
||||||
|
|
||||||
// optional: clamp to avoid spiral-of-death on hitches
|
|
||||||
if (elapsed > NS_PER_SEC / 10) elapsed = NS_PER_SEC / 10;
|
|
||||||
|
|
||||||
t->accumulator_ns += elapsed;
|
|
||||||
|
|
||||||
long long period_ns = NS_PER_SEC / t->cycles_per_sec;
|
|
||||||
int cycles = (int)(t->accumulator_ns / period_ns);
|
|
||||||
t->accumulator_ns %= period_ns;
|
|
||||||
return cycles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How many cycles to run between glances at the wall clock. In fast mode there is
|
|
||||||
// no clock to keep pace with, so run a large batch before looking up.
|
|
||||||
#define FAST_BATCH 65536
|
|
||||||
|
|
||||||
unsigned long cycleCount = 0;
|
|
||||||
char *programFile = NULL;
|
|
||||||
|
|
||||||
// Memory Banks:
|
|
||||||
uint8_t Program[0x10000], Data[0x10000];
|
|
||||||
|
|
||||||
// How the run is reported. The idle half is mentioned only when there is one, so that
|
|
||||||
// every program written before WAIT existed prints exactly the line it always did.
|
|
||||||
//
|
//
|
||||||
// THE TWO ARE NOT THE SAME KIND OF TIME. A bus cycle is the machine using memory; an idle
|
// ---- The machine with a terminal attached ----
|
||||||
// cycle is the machine stopped in a WAIT while a device catches up. Added together they
|
//
|
||||||
// are elapsed time, which is what a cycle limit measures; told apart they say whether a
|
// This is a front end and nothing else. The machine itself is in machine.c, shared with
|
||||||
// program was working or waiting.
|
// Voyager, which is the same machine with a screen and a speaker instead of a terminal.
|
||||||
static void reportCycles(const CPURegisters *cpu, unsigned long cycleCount) {
|
//
|
||||||
if (cpu->idleCycles > 0) {
|
// Keeping this file small is the point rather than a side effect: anything that ends up
|
||||||
printf("Execution halted after %lu cycles, %lu of them waiting.\n",
|
// here is behaviour Voyager does not have, and the two are supposed to differ only in
|
||||||
cycleCount, cpu->idleCycles);
|
// what they present. This builds and runs anywhere, with no graphics library, which is
|
||||||
} else {
|
// what keeps the whole toolchain and the whole suite dependency free.
|
||||||
printf("Execution halted after %lu cycles.\n", cycleCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main (int argc, char *argv[]) {
|
#include "machine.h"
|
||||||
|
#include "utility.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <getopt.h>
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
EmulatorOptions options;
|
EmulatorOptions options;
|
||||||
uint8_t result = parseOptions(argc, argv, &options);
|
uint8_t result = parseOptions(argc, argv, &options);
|
||||||
if (result == OPTIONS_HELP) {
|
if (result == OPTIONS_HELP) {
|
||||||
@@ -94,6 +30,7 @@ int main (int argc, char *argv[]) {
|
|||||||
// Bad command line, don't execute.
|
// Bad command line, don't execute.
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
char *programFile = NULL;
|
||||||
if (optind < argc) {
|
if (optind < argc) {
|
||||||
programFile = argv[optind];
|
programFile = argv[optind];
|
||||||
optind++;
|
optind++;
|
||||||
@@ -102,150 +39,21 @@ int main (int argc, char *argv[]) {
|
|||||||
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
|
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
// ---- Where the machine's first instruction comes from ----
|
|
||||||
//
|
Machine machine;
|
||||||
// Named an image, it is placed into memory and started - which is what a debugger
|
uint8_t started = machineStart(&machine, &options, programFile);
|
||||||
// does, and is how every test here runs. That path is not a shortcut to apologise
|
if (started == MACHINE_NOTHING_TO_RUN) {
|
||||||
// for: placing memory from outside is a real thing real machines allow.
|
|
||||||
//
|
|
||||||
// Named none, the machine starts the way hardware would: the ROM is shadowed into
|
|
||||||
// Program Memory and it reads the disk for the rest. There has to be a disk for that
|
|
||||||
// to mean anything, and no image and no disk is a machine with nothing to run.
|
|
||||||
if (programFile == NULL && options.disk == NULL) {
|
|
||||||
fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n");
|
fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n");
|
||||||
printHelp(argv[0]);
|
printHelp(argv[0]);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
} else if (started != MACHINE_OK) {
|
||||||
if (programFile != NULL) {
|
|
||||||
if (loadFile(programFile, Program, Data)) {
|
|
||||||
fprintf(stderr, "Error: Couldn't read file: %s\n", programFile);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
} else if (loadROM(bootROM, bootROMBytes, Program, Data)) {
|
|
||||||
fprintf(stderr, "Error: The boot ROM is not a boot image.\n");
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (options.disk != NULL && attachDisk(options.disk, options.writeProtect)) {
|
|
||||||
return 1;
|
while (machineRunning(&machine)) {
|
||||||
}
|
machineRunSlice(&machine);
|
||||||
CPURegisters cpu;
|
|
||||||
// The controller has to know where the memories are before anything can reach
|
|
||||||
// them through it. Banks 0 and 1 are those two arrays.
|
|
||||||
initializeController(Program, Data);
|
|
||||||
initializeCPU(&cpu, Program, Data);
|
|
||||||
if(options.debug) {
|
|
||||||
printRegisters(&cpu, Program, Data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CycleTimer timer;
|
machineStop(&machine);
|
||||||
setDiskLatency(options.diskCycles);
|
return machineReport(&machine);
|
||||||
cycle_timer_init(&timer, CYCLE_RATE);
|
|
||||||
|
|
||||||
uint8_t limitReached = 0;
|
|
||||||
while (!(cpu.Status & STATUS_HALT) && !limitReached) {
|
|
||||||
if (options.debug) {
|
|
||||||
// Wait before advancing, not after, so that a keypress is what moves the
|
|
||||||
// machine on rather than something that happens once it already has.
|
|
||||||
// Through the console rather than getchar, so that everything reading standard
|
|
||||||
// input reads it the same way and the console's pushback stays the only place
|
|
||||||
// a byte can be sitting.
|
|
||||||
consoleReadByte();
|
|
||||||
}
|
|
||||||
int cycles;
|
|
||||||
if (options.debug) {
|
|
||||||
// Debug mode advances one instruction per keypress, so the wall clock
|
|
||||||
// has no say in how many cycles to run.
|
|
||||||
cycles = 1;
|
|
||||||
} else if (options.fast) {
|
|
||||||
cycles = FAST_BATCH;
|
|
||||||
} else {
|
|
||||||
cycles = cycle_timer_tick(&timer);
|
|
||||||
}
|
|
||||||
// ---- Spending a budget of cycles, not running a count of instructions ----
|
|
||||||
//
|
|
||||||
// An instruction costs what it touches, so a batch is finished when the cycles are
|
|
||||||
// gone rather than after so many steps. In debug mode the budget is one, and any
|
|
||||||
// instruction costs at least the fetch of its own opcode, so one step still runs.
|
|
||||||
for (long spent = 0; spent < cycles; ) {
|
|
||||||
// Both kinds of cycle, because both are time passing. A step that waits
|
|
||||||
// spends no bus at all, and a budget measured only in bus cycles would never
|
|
||||||
// be spent - the machine would sit inside one batch forever and the device it
|
|
||||||
// was waiting for would never be given a moment to finish.
|
|
||||||
unsigned long before = cpu.busCycles + cpu.idleCycles;
|
|
||||||
stepCPU(&cpu);
|
|
||||||
unsigned long took = (cpu.busCycles + cpu.idleCycles) - before;
|
|
||||||
spent += (long)took;
|
|
||||||
cycleCount += took;
|
|
||||||
// Time has passed, so anything waiting on it may be finished.
|
|
||||||
deviceTick(cycleCount);
|
|
||||||
|
|
||||||
// ---- Starting over ----
|
|
||||||
//
|
|
||||||
// Between instructions, which is the only place it can happen: a device cannot
|
|
||||||
// restart the machine from inside the instruction that asked for it.
|
|
||||||
//
|
|
||||||
// WHAT A RESET REPEATS IS HOW THIS MACHINE STARTED. Named an image, it is
|
|
||||||
// placed again; named none, the ROM is shadowed again and reads the disk for
|
|
||||||
// the rest. Anything else would mean a reset changed what the machine is,
|
|
||||||
// which is the one thing a reset must not do.
|
|
||||||
//
|
|
||||||
// The disk is not unplugged and its image keeps everything written to it. That
|
|
||||||
// is what warm means: the machine starts again, the world it starts into does
|
|
||||||
// not.
|
|
||||||
if (takeResetRequest()) {
|
|
||||||
// The vector table goes, and that is a deliberate departure from leaving
|
|
||||||
// memory alone. A vector points into whatever installed it, and after this
|
|
||||||
// that program is not running - so a handler left behind would aim an
|
|
||||||
// interrupt at an address belonging to something gone. It is the argument
|
|
||||||
// CosmOS already makes when it takes a program's vectors back at exit.
|
|
||||||
memset(Program + SOFTWARE_VECTOR_BASE, 0,
|
|
||||||
(size_t)(0x10000 - SOFTWARE_VECTOR_BASE));
|
|
||||||
uint8_t failed = (programFile != NULL)
|
|
||||||
? loadFile(programFile, Program, Data)
|
|
||||||
: loadROM(bootROM, bootROMBytes, Program, Data);
|
|
||||||
if (failed) {
|
|
||||||
fprintf(stderr, "Error: The machine could not be started again.\n");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
initializeCPU(&cpu, Program, Data);
|
|
||||||
break; // Out of this batch; the loop above carries on with a new CPU.
|
|
||||||
}
|
|
||||||
if (cpu.Status & STATUS_HALT) {
|
|
||||||
// We've halted.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (options.cycles && cycleCount >= options.cycles) {
|
|
||||||
limitReached = 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.debug) {
|
|
||||||
printRegisters(&cpu, Program, Data);
|
|
||||||
printf("Cycle: %lu\n", cycleCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
detachDisk();
|
|
||||||
if (limitReached) {
|
|
||||||
printf("Execution stopped after %lu cycles. (cycle limit reached)\n", cycleCount);
|
|
||||||
} else if (cpu.Status & STATUS_FAULT) {
|
|
||||||
// The Program Counter is still pointing at whatever the CPU could not get past.
|
|
||||||
reportCycles(&cpu, cycleCount);
|
|
||||||
if (cpu.Fault == FAULT_NO_HANDLER) {
|
|
||||||
fprintf(stderr, "Fault: Software vector %u, dispatched from Program Address 0x%04X, has no handler installed.\n",
|
|
||||||
cpu.FaultVector, cpu.ProgramCounter);
|
|
||||||
} else if (cpu.Fault == FAULT_DEVICE_REFUSED) {
|
|
||||||
fprintf(stderr, "Fault: The device on port %u refused the access at Program Address 0x%04X, and nothing is installed to deal with it.\n",
|
|
||||||
cpu.FaultVector, cpu.ProgramCounter);
|
|
||||||
} else if (cpu.Fault == FAULT_NO_DEVICE_HANDLER) {
|
|
||||||
fprintf(stderr, "Fault: The device on port %u interrupted at Program Address 0x%04X, and hardware vector %u has no handler installed.\n",
|
|
||||||
cpu.FaultVector, cpu.ProgramCounter, cpu.FaultVector);
|
|
||||||
} else {
|
|
||||||
fprintf(stderr, "Fault: 0x%02X at Program Address 0x%04X is not an instruction.\n",
|
|
||||||
Program[cpu.ProgramCounter], cpu.ProgramCounter);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
reportCycles(&cpu, cycleCount);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// machine.c
|
||||||
|
// The SplitBit machine: everything both front ends share.
|
||||||
|
// Written by Anachronaut
|
||||||
|
|
||||||
|
#include "machine.h"
|
||||||
|
#include "rom.h"
|
||||||
|
#include "bootstrap.h"
|
||||||
|
#include "cpu.h"
|
||||||
|
#include "controller.h"
|
||||||
|
#include "io.h"
|
||||||
|
#include "utility.h"
|
||||||
|
#include "../Assembler/assembly.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// nanoseconds per second
|
||||||
|
#define NS_PER_SEC 1000000000LL
|
||||||
|
|
||||||
|
static inline long long timespec_diff_ns(struct timespec a, struct timespec b) {
|
||||||
|
return (a.tv_sec - b.tv_sec) * NS_PER_SEC + (a.tv_nsec - b.tv_nsec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec) {
|
||||||
|
t->cycles_per_sec = cycles_per_sec;
|
||||||
|
t->accumulator_ns = 0;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &t->prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call once per host frame. Returns how many SplitBit cycles to execute.
|
||||||
|
int cycle_timer_tick(CycleTimer *t) {
|
||||||
|
struct timespec now;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||||
|
|
||||||
|
long long elapsed = timespec_diff_ns(now, t->prev);
|
||||||
|
t->prev = now;
|
||||||
|
|
||||||
|
// optional: clamp to avoid spiral-of-death on hitches
|
||||||
|
if (elapsed > NS_PER_SEC / 10) elapsed = NS_PER_SEC / 10;
|
||||||
|
|
||||||
|
t->accumulator_ns += elapsed;
|
||||||
|
|
||||||
|
long long period_ns = NS_PER_SEC / t->cycles_per_sec;
|
||||||
|
int cycles = (int)(t->accumulator_ns / period_ns);
|
||||||
|
t->accumulator_ns %= period_ns;
|
||||||
|
return cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many cycles to run between glances at the wall clock. In fast mode there is
|
||||||
|
// no clock to keep pace with, so run a large batch before looking up.
|
||||||
|
#define FAST_BATCH 65536
|
||||||
|
|
||||||
|
// Memory Banks. Static, because a front end has no business reaching into them: what it
|
||||||
|
// needs to know about the machine it asks the machine.
|
||||||
|
static uint8_t Program[0x10000], Data[0x10000];
|
||||||
|
|
||||||
|
// How the run is reported. The idle half is mentioned only when there is one, so that
|
||||||
|
// every program written before WAIT existed prints exactly the line it always did.
|
||||||
|
//
|
||||||
|
// THE TWO ARE NOT THE SAME KIND OF TIME. A bus cycle is the machine using memory; an idle
|
||||||
|
// cycle is the machine stopped in a WAIT while a device catches up. Added together they
|
||||||
|
// are elapsed time, which is what a cycle limit measures; told apart they say whether a
|
||||||
|
// program was working or waiting.
|
||||||
|
static void reportCycles(const CPURegisters *cpu, unsigned long cycleCount) {
|
||||||
|
if (cpu->idleCycles > 0) {
|
||||||
|
printf("Execution halted after %lu cycles, %lu of them waiting.\n",
|
||||||
|
cycleCount, cpu->idleCycles);
|
||||||
|
} else {
|
||||||
|
printf("Execution halted after %lu cycles.\n", cycleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *programFile) {
|
||||||
|
m->options = *options;
|
||||||
|
m->programFile = programFile;
|
||||||
|
m->cycleCount = 0;
|
||||||
|
m->limitReached = 0;
|
||||||
|
m->restartFailed = 0;
|
||||||
|
|
||||||
|
// ---- Where the machine's first instruction comes from ----
|
||||||
|
//
|
||||||
|
// Named an image, it is placed into memory and started - which is what a debugger
|
||||||
|
// does, and is how every test here runs. That path is not a shortcut to apologise
|
||||||
|
// for: placing memory from outside is a real thing real machines allow.
|
||||||
|
//
|
||||||
|
// Named none, the machine starts the way hardware would: the ROM is shadowed into
|
||||||
|
// Program Memory and it reads the disk for the rest. There has to be a disk for that
|
||||||
|
// to mean anything, and no image and no disk is a machine with nothing to run.
|
||||||
|
if (programFile == NULL && options->disk == NULL) {
|
||||||
|
return MACHINE_NOTHING_TO_RUN;
|
||||||
|
}
|
||||||
|
if (programFile != NULL) {
|
||||||
|
if (loadFile(programFile, Program, Data)) {
|
||||||
|
fprintf(stderr, "Error: Couldn't read file: %s\n", programFile);
|
||||||
|
return MACHINE_ERROR;
|
||||||
|
}
|
||||||
|
} else if (loadROM(bootROM, bootROMBytes, Program, Data)) {
|
||||||
|
fprintf(stderr, "Error: The boot ROM is not a boot image.\n");
|
||||||
|
return MACHINE_ERROR;
|
||||||
|
}
|
||||||
|
if (options->disk != NULL && attachDisk(options->disk, options->writeProtect)) {
|
||||||
|
return MACHINE_ERROR;
|
||||||
|
}
|
||||||
|
// The controller has to know where the memories are before anything can reach
|
||||||
|
// them through it. Banks 0 and 1 are those two arrays.
|
||||||
|
initializeController(Program, Data);
|
||||||
|
initializeCPU(&m->cpu, Program, Data);
|
||||||
|
if (m->options.debug) {
|
||||||
|
printRegisters(&m->cpu, Program, Data);
|
||||||
|
}
|
||||||
|
setDiskLatency(m->options.diskCycles);
|
||||||
|
cycle_timer_init(&m->timer, CYCLE_RATE);
|
||||||
|
return MACHINE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
int machineRunning(const Machine *m) {
|
||||||
|
return !(m->cpu.Status & STATUS_HALT) && !m->limitReached && !m->restartFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void machineRunSlice(Machine *m) {
|
||||||
|
if (m->options.debug) {
|
||||||
|
// Wait before advancing, not after, so that a keypress is what moves the
|
||||||
|
// machine on rather than something that happens once it already has.
|
||||||
|
// Through the console rather than getchar, so that everything reading standard
|
||||||
|
// input reads it the same way and the console's pushback stays the only place
|
||||||
|
// a byte can be sitting.
|
||||||
|
consoleReadByte();
|
||||||
|
}
|
||||||
|
int cycles;
|
||||||
|
if (m->options.debug) {
|
||||||
|
// Debug mode advances one instruction per keypress, so the wall clock
|
||||||
|
// has no say in how many cycles to run.
|
||||||
|
cycles = 1;
|
||||||
|
} else if (m->options.fast) {
|
||||||
|
cycles = FAST_BATCH;
|
||||||
|
} else {
|
||||||
|
cycles = cycle_timer_tick(&m->timer);
|
||||||
|
}
|
||||||
|
// ---- Spending a budget of cycles, not running a count of instructions ----
|
||||||
|
//
|
||||||
|
// An instruction costs what it touches, so a batch is finished when the cycles are
|
||||||
|
// gone rather than after so many steps. In debug mode the budget is one, and any
|
||||||
|
// instruction costs at least the fetch of its own opcode, so one step still runs.
|
||||||
|
for (long spent = 0; spent < cycles; ) {
|
||||||
|
// Both kinds of cycle, because both are time passing. A step that waits
|
||||||
|
// spends no bus at all, and a budget measured only in bus cycles would never
|
||||||
|
// be spent - the machine would sit inside one batch forever and the device it
|
||||||
|
// was waiting for would never be given a moment to finish.
|
||||||
|
unsigned long before = m->cpu.busCycles + m->cpu.idleCycles;
|
||||||
|
stepCPU(&m->cpu);
|
||||||
|
unsigned long took = (m->cpu.busCycles + m->cpu.idleCycles) - before;
|
||||||
|
spent += (long)took;
|
||||||
|
m->cycleCount += took;
|
||||||
|
// Time has passed, so anything waiting on it may be finished.
|
||||||
|
deviceTick(m->cycleCount);
|
||||||
|
|
||||||
|
// ---- Starting over ----
|
||||||
|
//
|
||||||
|
// Between instructions, which is the only place it can happen: a device cannot
|
||||||
|
// restart the machine from inside the instruction that asked for it.
|
||||||
|
//
|
||||||
|
// WHAT A RESET REPEATS IS HOW THIS MACHINE STARTED. Named an image, it is
|
||||||
|
// placed again; named none, the ROM is shadowed again and reads the disk for
|
||||||
|
// the rest. Anything else would mean a reset changed what the machine is,
|
||||||
|
// which is the one thing a reset must not do.
|
||||||
|
//
|
||||||
|
// The disk is not unplugged and its image keeps everything written to it. That
|
||||||
|
// is what warm means: the machine starts again, the world it starts into does
|
||||||
|
// not.
|
||||||
|
if (takeResetRequest()) {
|
||||||
|
// The vector table goes, and that is a deliberate departure from leaving
|
||||||
|
// memory alone. A vector points into whatever installed it, and after this
|
||||||
|
// that program is not running - so a handler left behind would aim an
|
||||||
|
// interrupt at an address belonging to something gone. It is the argument
|
||||||
|
// CosmOS already makes when it takes a program's vectors back at exit.
|
||||||
|
memset(Program + SOFTWARE_VECTOR_BASE, 0,
|
||||||
|
(size_t)(0x10000 - SOFTWARE_VECTOR_BASE));
|
||||||
|
uint8_t failed = (m->programFile != NULL)
|
||||||
|
? loadFile(m->programFile, Program, Data)
|
||||||
|
: loadROM(bootROM, bootROMBytes, Program, Data);
|
||||||
|
if (failed) {
|
||||||
|
fprintf(stderr, "Error: The machine could not be started again.\n");
|
||||||
|
m->restartFailed = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initializeCPU(&m->cpu, Program, Data);
|
||||||
|
break; // Out of this batch; the loop above carries on with a new CPU.
|
||||||
|
}
|
||||||
|
if (m->cpu.Status & STATUS_HALT) {
|
||||||
|
// We've halted.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (m->options.cycles && m->cycleCount >= m->options.cycles) {
|
||||||
|
m->limitReached = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m->options.debug) {
|
||||||
|
printRegisters(&m->cpu, Program, Data);
|
||||||
|
printf("Cycle: %lu\n", m->cycleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void machineStop(Machine *m) {
|
||||||
|
(void)m;
|
||||||
|
detachDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
int machineReport(const Machine *m) {
|
||||||
|
if (m->restartFailed) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (m->limitReached) {
|
||||||
|
printf("Execution stopped after %lu cycles. (cycle limit reached)\n", m->cycleCount);
|
||||||
|
} else if (m->cpu.Status & STATUS_FAULT) {
|
||||||
|
// The Program Counter is still pointing at whatever the CPU could not get past.
|
||||||
|
reportCycles(&m->cpu, m->cycleCount);
|
||||||
|
if (m->cpu.Fault == FAULT_NO_HANDLER) {
|
||||||
|
fprintf(stderr, "Fault: Software vector %u, dispatched from Program Address 0x%04X, has no handler installed.\n",
|
||||||
|
m->cpu.FaultVector, m->cpu.ProgramCounter);
|
||||||
|
} else if (m->cpu.Fault == FAULT_DEVICE_REFUSED) {
|
||||||
|
fprintf(stderr, "Fault: The device on port %u refused the access at Program Address 0x%04X, and nothing is installed to deal with it.\n",
|
||||||
|
m->cpu.FaultVector, m->cpu.ProgramCounter);
|
||||||
|
} else if (m->cpu.Fault == FAULT_NO_DEVICE_HANDLER) {
|
||||||
|
fprintf(stderr, "Fault: The device on port %u interrupted at Program Address 0x%04X, and hardware vector %u has no handler installed.\n",
|
||||||
|
m->cpu.FaultVector, m->cpu.ProgramCounter, m->cpu.FaultVector);
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "Fault: 0x%02X at Program Address 0x%04X is not an instruction.\n",
|
||||||
|
Program[m->cpu.ProgramCounter], m->cpu.ProgramCounter);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
reportCycles(&m->cpu, m->cycleCount);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// machine.h
|
||||||
|
// The SplitBit machine, with nothing attached to look at it.
|
||||||
|
// Written by Anachronaut
|
||||||
|
|
||||||
|
#ifndef MACHINE_H
|
||||||
|
#define MACHINE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include "cpu.h"
|
||||||
|
#include "utility.h"
|
||||||
|
|
||||||
|
// ---- Why the machine is not a main ----
|
||||||
|
//
|
||||||
|
// There are two front ends: SplitBit, which is a terminal, and Voyager, which is a window
|
||||||
|
// and a speaker. THEY ARE THE SAME MACHINE. Keeping it here means the difference between
|
||||||
|
// them is presentation and nothing else - neither can quietly acquire behaviour the other
|
||||||
|
// lacks, and the suite can hold them to it by running a program through both and comparing
|
||||||
|
// what came out.
|
||||||
|
//
|
||||||
|
// It also decides where the devices live. A video device that only existed in the windowed
|
||||||
|
// binary would be untestable, because the suite has no display; here it is part of the
|
||||||
|
// machine, advances on emulated cycles, and Raylib only presents what it already produced.
|
||||||
|
|
||||||
|
// How fast the machine runs when it is not being told to hurry.
|
||||||
|
#define CYCLE_RATE 1000000
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
long long cycles_per_sec;
|
||||||
|
long long accumulator_ns;
|
||||||
|
struct timespec prev;
|
||||||
|
} CycleTimer;
|
||||||
|
|
||||||
|
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec);
|
||||||
|
|
||||||
|
// Call once per host frame. Returns how many SplitBit cycles to execute.
|
||||||
|
int cycle_timer_tick(CycleTimer *t);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
CPURegisters cpu;
|
||||||
|
CycleTimer timer;
|
||||||
|
unsigned long cycleCount;
|
||||||
|
uint8_t limitReached;
|
||||||
|
uint8_t restartFailed;
|
||||||
|
const char *programFile;
|
||||||
|
EmulatorOptions options;
|
||||||
|
} Machine;
|
||||||
|
|
||||||
|
#define MACHINE_OK 0
|
||||||
|
#define MACHINE_ERROR 1
|
||||||
|
// No image and no disk. Told apart from an ordinary failure so that the front end can
|
||||||
|
// print its own help, which is the one thing here that knows what it is called.
|
||||||
|
#define MACHINE_NOTHING_TO_RUN 2
|
||||||
|
|
||||||
|
uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *programFile);
|
||||||
|
|
||||||
|
// Whether there is any more running to do. False once the machine has halted, faulted, or
|
||||||
|
// spent the cycle limit it was given.
|
||||||
|
int machineRunning(const Machine *m);
|
||||||
|
|
||||||
|
// ---- One slice, not the whole run ----
|
||||||
|
//
|
||||||
|
// A front end with a window has to get a turn: run a slice, present a frame, run another.
|
||||||
|
// A terminal simply runs slices until the machine stops. Both loops are three lines, which
|
||||||
|
// is the point of cutting it here rather than anywhere else.
|
||||||
|
void machineRunSlice(Machine *m);
|
||||||
|
|
||||||
|
void machineStop(Machine *m);
|
||||||
|
|
||||||
|
// Says how the run went and returns what the process should exit with.
|
||||||
|
int machineReport(const Machine *m);
|
||||||
|
|
||||||
|
#endif // MACHINE_H
|
||||||
@@ -28,7 +28,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options);
|
|||||||
|
|
||||||
void printHelp(const char *programName);
|
void printHelp(const char *programName);
|
||||||
|
|
||||||
uint8_t loadFile(char *path, uint8_t *Program, uint8_t *Data);
|
uint8_t loadFile(const char *path, uint8_t *Program, uint8_t *Data);
|
||||||
|
|
||||||
void bootStrap(uint8_t *Program, uint8_t *Data);
|
void bootStrap(uint8_t *Program, uint8_t *Data);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// voyager.c
|
||||||
|
|
||||||
|
// The Segan Voyager
|
||||||
|
// A SplitBit with a screen and a speaker attached
|
||||||
|
// Written by Anachronaut
|
||||||
|
//
|
||||||
|
// ---- What this is ----
|
||||||
|
//
|
||||||
|
// The same machine SplitBit runs, presented through a window instead of a terminal. Every
|
||||||
|
// instruction, every device and every cycle is in machine.c and shared; this file opens a
|
||||||
|
// window, gives the machine a slice of time per frame, and shows what came out.
|
||||||
|
//
|
||||||
|
// THAT ORDER MATTERS AND IS THE WHOLE DESIGN. The devices belong to the machine and advance
|
||||||
|
// on emulated cycles, so the same program produces the same frames and the same samples
|
||||||
|
// whether or not anybody is looking. Raylib presents; it does not decide. Which is what
|
||||||
|
// lets a test suite with no display hold this binary to the same behaviour as the other
|
||||||
|
// one.
|
||||||
|
//
|
||||||
|
// At this stage the window is empty. There is no video device yet, and inventing a
|
||||||
|
// temporary way to draw would mean building something to throw away.
|
||||||
|
|
||||||
|
#include "machine.h"
|
||||||
|
#include "utility.h"
|
||||||
|
#include "raylib.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <getopt.h>
|
||||||
|
|
||||||
|
// The screen the Voyager will have, scaled up because a 320 by 200 window is a postage
|
||||||
|
// stamp on a modern display. Both numbers are provisional until the video device decides
|
||||||
|
// them for real.
|
||||||
|
#define SCREEN_WIDTH 320
|
||||||
|
#define SCREEN_HEIGHT 200
|
||||||
|
#define SCREEN_SCALE 3
|
||||||
|
|
||||||
|
// ---- Running without a window ----
|
||||||
|
//
|
||||||
|
// Taken out of the arguments here rather than in the shared parser, because it is a fact
|
||||||
|
// about this front end and the shared parser should not learn about a window that only one
|
||||||
|
// binary has. Everything else on the command line means exactly what it means to SplitBit.
|
||||||
|
//
|
||||||
|
// It exists so the suite can run this binary at all: a test machine has no display, and a
|
||||||
|
// front end that could only be exercised by a person looking at it would be a front end
|
||||||
|
// nothing checks. Headless, Voyager must print byte for byte what SplitBit prints, and
|
||||||
|
// Tests/voyager.sh holds it to that.
|
||||||
|
static int takeHeadless(int *argc, char *argv[]) {
|
||||||
|
int headless = 0;
|
||||||
|
int out = 0;
|
||||||
|
for (int i = 0; i < *argc; i++) {
|
||||||
|
if (strcmp(argv[i], "--headless") == 0) {
|
||||||
|
headless = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
argv[out++] = argv[i];
|
||||||
|
}
|
||||||
|
argv[out] = NULL;
|
||||||
|
*argc = out;
|
||||||
|
return headless;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
int headless = takeHeadless(&argc, argv);
|
||||||
|
|
||||||
|
EmulatorOptions options;
|
||||||
|
uint8_t result = parseOptions(argc, argv, &options);
|
||||||
|
if (result == OPTIONS_HELP) {
|
||||||
|
printf(" --headless Run with no window, which is how the tests run it.\n");
|
||||||
|
return 0;
|
||||||
|
} else if (result == OPTIONS_ERROR) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
char *programFile = NULL;
|
||||||
|
if (optind < argc) {
|
||||||
|
programFile = argv[optind];
|
||||||
|
optind++;
|
||||||
|
}
|
||||||
|
if (optind < argc) {
|
||||||
|
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Machine machine;
|
||||||
|
uint8_t started = machineStart(&machine, &options, programFile);
|
||||||
|
if (started == MACHINE_NOTHING_TO_RUN) {
|
||||||
|
fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n");
|
||||||
|
printHelp(argv[0]);
|
||||||
|
return 1;
|
||||||
|
} else if (started != MACHINE_OK) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headless) {
|
||||||
|
// The same three lines SplitBit runs, and deliberately so: a headless Voyager is
|
||||||
|
// not a reduced machine, it is the machine with nobody watching.
|
||||||
|
while (machineRunning(&machine)) {
|
||||||
|
machineRunSlice(&machine);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
InitWindow(SCREEN_WIDTH * SCREEN_SCALE, SCREEN_HEIGHT * SCREEN_SCALE,
|
||||||
|
"Segan Voyager");
|
||||||
|
SetTargetFPS(60);
|
||||||
|
// ---- A slice a frame ----
|
||||||
|
//
|
||||||
|
// The machine gets its turn, then the window gets its turn. Closing the window
|
||||||
|
// stops the machine, and the machine halting leaves the window up so that whatever
|
||||||
|
// it drew is still there to look at - a program that ends should not take its
|
||||||
|
// output off the screen with it.
|
||||||
|
while (!WindowShouldClose()) {
|
||||||
|
if (machineRunning(&machine)) {
|
||||||
|
machineRunSlice(&machine);
|
||||||
|
}
|
||||||
|
BeginDrawing();
|
||||||
|
// Not black. A screen with nothing driving it should look like a screen that
|
||||||
|
// is on, rather than like a window that failed to open.
|
||||||
|
ClearBackground((Color){ 18, 22, 20, 255 });
|
||||||
|
EndDrawing();
|
||||||
|
}
|
||||||
|
CloseWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
machineStop(&machine);
|
||||||
|
return machineReport(&machine);
|
||||||
|
}
|
||||||
+27
-9
@@ -9,7 +9,7 @@ believe them.
|
|||||||
|
|
||||||
## What The Suite Claims:
|
## What The Suite Claims:
|
||||||
|
|
||||||
The suite is not one thing. It is eight scripts making five different kinds of claim, and
|
The suite is not one thing. It is nine 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.
|
||||||
@@ -37,8 +37,8 @@ Everything in between is somewhere on that line.
|
|||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
Builds the four tools, checks they compile under strict ISO C, and runs the scripts in
|
Builds the four tools - and Voyager, where Raylib is installed - checks they compile under
|
||||||
order. Takes a few seconds. Everything must pass; there are no expected failures at the
|
strict ISO C, and runs the scripts in order. Takes a few seconds. Everything must pass; there are no expected failures at the
|
||||||
level of the suite, only tests that record an expected failure of the assembler.
|
level of the suite, only tests that record an expected failure of the assembler.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -54,6 +54,7 @@ Individual scripts can be run on their own, from anywhere:
|
|||||||
./Tests/run.sh Every program in the manifest.
|
./Tests/run.sh Every program in the manifest.
|
||||||
./Tests/run.sh hello waitTest Only the named ones.
|
./Tests/run.sh hello waitTest Only the named ones.
|
||||||
./Tests/run.sh --bless Record current output as expected. See below.
|
./Tests/run.sh --bless Record current output as expected. See below.
|
||||||
|
./Tests/voyager.sh The same manifest, through the other front end.
|
||||||
./Tests/disk.sh The disk tool against the format.
|
./Tests/disk.sh The disk tool against the format.
|
||||||
./Tests/cycles.sh What the memory controller charges.
|
./Tests/cycles.sh What the memory controller charges.
|
||||||
./Tests/terminal.sh The things a recorded file cannot see.
|
./Tests/terminal.sh The things a recorded file cannot see.
|
||||||
@@ -91,8 +92,8 @@ without anybody meaning it to.
|
|||||||
|
|
||||||
### 2. A second implementation
|
### 2. A second implementation
|
||||||
|
|
||||||
`Tests/native.sh` and `Tests/agree.sh` are the two checks that do not compare the code
|
`Tests/native.sh`, `Tests/agree.sh` and `Tests/voyager.sh` are the checks that do not
|
||||||
against a memory of itself.
|
compare the code against a memory of itself.
|
||||||
|
|
||||||
`native.sh` assembles the same source with the host assembler and with `Asm.sbx` running on
|
`native.sh` assembles the same source with the host assembler and with `Asm.sbx` running on
|
||||||
the emulated machine, and compares the two binaries byte for byte. **The only honest test of
|
the emulated machine, and compares the two binaries byte for byte. **The only honest test of
|
||||||
@@ -109,10 +110,27 @@ block, what a directory's unused fields hold, the version in the superblock, the
|
|||||||
count. A disagreement in any of those is a disk one of them can read and the other cannot,
|
count. A disagreement in any of those is a disk one of them can read and the other cannot,
|
||||||
and the usual way that gets discovered is somebody's file coming back wrong months later.
|
and the usual way that gets discovered is somebody's file coming back wrong months later.
|
||||||
|
|
||||||
Both scripts depend on both sides being driven in the same order, because both allocate
|
`native.sh` and `agree.sh` both depend on the two sides being driven in the same order,
|
||||||
first fit and both take the first free entry. Given the same operations in the same
|
because both allocate first fit and both take the first free entry. Given the same
|
||||||
sequence they should reach the same bytes, and any difference is real rather than an
|
operations in the same sequence they should reach the same bytes, and any difference is real
|
||||||
artefact of the script.
|
rather than an artefact of the script.
|
||||||
|
|
||||||
|
`Tests/voyager.sh` is the cheapest of the three, because it reuses the runner rather than
|
||||||
|
repeating it. SplitBit and Voyager share every line of the machine and differ only in what
|
||||||
|
they present - a terminal, or a window and a speaker - and the way to keep that true is to
|
||||||
|
make the claim testable. So it runs the **whole manifest** through Voyager with
|
||||||
|
`--headless`, held to the same recorded results SplitBit is held to. The claim is not that
|
||||||
|
the two look alike: it is that one satisfies every recording the other does, byte for byte,
|
||||||
|
exit status included.
|
||||||
|
|
||||||
|
`Tests/run.sh` takes the machine to run from `SPLITBIT_EMULATOR`, which is what makes that
|
||||||
|
possible without a second copy of the runner. A copy would drift, and the first thing to go
|
||||||
|
would be whichever awkward case got added to only one of them.
|
||||||
|
|
||||||
|
**Voyager not being built is not a failure.** It needs Raylib and nothing else here does,
|
||||||
|
which is the whole point of there being two binaries; a suite that failed on a machine with
|
||||||
|
no graphics library would be enforcing exactly the dependency the split exists to avoid. The
|
||||||
|
script says it was skipped, and passes.
|
||||||
|
|
||||||
### 3. Named properties
|
### 3. Named properties
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -668,9 +668,11 @@ elif int(said.group(1)) != pairs:
|
|||||||
#
|
#
|
||||||
# Claimed in both documents, so both are read.
|
# Claimed in both documents, so both are read.
|
||||||
makefile = open("makefile").read()
|
makefile = open("makefile").read()
|
||||||
built = re.search(r"^all:(.*)$", makefile, re.M)
|
# TOOLS rather than the all target, which now depends on whether Raylib is installed.
|
||||||
|
# What "the tools" means should be a fact in one place, not read off a conditional.
|
||||||
|
built = re.search(r"^TOOLS = (.*)$", makefile, re.M)
|
||||||
if not built:
|
if not built:
|
||||||
problems.append("the makefile no longer has an all target this can count")
|
problems.append("the makefile no longer has a TOOLS list this can count")
|
||||||
else:
|
else:
|
||||||
tools = len(built.group(1).split())
|
tools = len(built.group(1).split())
|
||||||
for where, text in (("README", rootReadme), ("Test Manual", manual)):
|
for where, text in (("README", rootReadme), ("Test Manual", manual)):
|
||||||
|
|||||||
+15
-4
@@ -23,7 +23,16 @@ INPUT="$TESTS/input"
|
|||||||
MANIFEST="$TESTS/manifest"
|
MANIFEST="$TESTS/manifest"
|
||||||
|
|
||||||
ASSEMBLER="$ROOT/Assembler"
|
ASSEMBLER="$ROOT/Assembler"
|
||||||
EMULATOR="$ROOT/SplitBit"
|
|
||||||
|
# ---- Which machine runs the programs ----
|
||||||
|
#
|
||||||
|
# SplitBit unless something says otherwise. Tests/voyager.sh sets these to run the whole
|
||||||
|
# manifest through the OTHER front end and hold it to the same recorded results, which is
|
||||||
|
# the strongest thing that can be said about the two of them: not that they look alike, but
|
||||||
|
# that one satisfies every recording the other does. Doing it this way rather than by
|
||||||
|
# copying this file means the two can never be tested differently by accident.
|
||||||
|
EMULATOR="${SPLITBIT_EMULATOR:-$ROOT/SplitBit}"
|
||||||
|
read -r -a EMULATOR_EXTRA <<< "${SPLITBIT_EMULATOR_ARGS:-}"
|
||||||
|
|
||||||
RUN_TIMEOUT=10
|
RUN_TIMEOUT=10
|
||||||
|
|
||||||
@@ -221,7 +230,7 @@ while IFS='|' read -r name src mode stdin limit disk; do
|
|||||||
# --fast because there is nothing to learn from waiting out the emulated
|
# --fast because there is nothing to learn from waiting out the emulated
|
||||||
# clock, and --cycles for programs that never halt on their own, which
|
# clock, and --cycles for programs that never halt on their own, which
|
||||||
# bounds them by cycle count rather than by wall clock.
|
# bounds them by cycle count rather than by wall clock.
|
||||||
EMUARGS=(--fast)
|
EMUARGS=(--fast "${EMULATOR_EXTRA[@]}")
|
||||||
[ "$limit" != "-" ] && EMUARGS+=(--cycles "$limit")
|
[ "$limit" != "-" ] && EMUARGS+=(--cycles "$limit")
|
||||||
# A disk starts fresh for every run, so a test cannot pass because of what a
|
# A disk starts fresh for every run, so a test cannot pass because of what a
|
||||||
# previous one left lying on it. The emulator makes the image if it is
|
# previous one left lying on it. The emulator makes the image if it is
|
||||||
@@ -284,9 +293,11 @@ if [ "$BLESS" -eq 1 ]; then
|
|||||||
echo "Recorded $BLESSED expected results into Tests/expected."
|
echo "Recorded $BLESSED expected results into Tests/expected."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
WHICH=""
|
||||||
|
[ "$EMULATOR" = "$ROOT/SplitBit" ] || WHICH=" under $(basename "$EMULATOR")"
|
||||||
if [ "$FAIL" -eq 0 ]; then
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
echo "All $PASS tests passed."
|
echo "All $PASS tests passed$WHICH."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
echo "$PASS passed, $FAIL failed$WHICH: ${FAILED_NAMES[*]}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Checks that Voyager is the same machine as SplitBit.
|
||||||
|
#
|
||||||
|
# They share every line of the machine and differ only in what they present, and the way to
|
||||||
|
# keep that true is to make the claim testable rather than to promise it in a comment. So
|
||||||
|
# this runs the WHOLE MANIFEST through Voyager and holds it to the recorded results that
|
||||||
|
# SplitBit is held to - not that the two look alike, but that one satisfies every recording
|
||||||
|
# the other does, byte for byte, exit status included.
|
||||||
|
#
|
||||||
|
# It reuses Tests/run.sh rather than repeating it. A copy of the runner that drove the other
|
||||||
|
# binary would drift from this one, and the first thing to go would be whichever awkward
|
||||||
|
# case was added to only one of them.
|
||||||
|
#
|
||||||
|
# Voyager needs a window, and a machine running tests usually has no display, so it is run
|
||||||
|
# with --headless. That flag exists for this: a front end that could only be exercised by a
|
||||||
|
# person looking at it would be a front end nothing checks.
|
||||||
|
#
|
||||||
|
# Written by Anachronaut
|
||||||
|
|
||||||
|
set -u
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
# ---- Not built is not a failure ----
|
||||||
|
#
|
||||||
|
# Voyager is built only where Raylib is, and the whole point of the split is that everything
|
||||||
|
# else works without it. A suite that failed here on a machine with no graphics library
|
||||||
|
# would be enforcing exactly the dependency the split exists to avoid.
|
||||||
|
if [ ! -x "$ROOT/Voyager" ]; then
|
||||||
|
echo "Voyager is not built, so it was not checked. Raylib is what it needs."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking that Voyager is the same machine."
|
||||||
|
SPLITBIT_EMULATOR="$ROOT/Voyager" SPLITBIT_EMULATOR_ARGS="--headless" "$ROOT/Tests/run.sh" \
|
||||||
|
| tail -3
|
||||||
|
exit "${PIPESTATUS[0]}"
|
||||||
@@ -33,24 +33,63 @@ SRC_DIR_LINT = Source/Linter
|
|||||||
OBJ_DIR = Object
|
OBJ_DIR = Object
|
||||||
|
|
||||||
# Source files
|
# Source files
|
||||||
EMU_SRCS = emulator.c io.c controller.c utility.c cpu.c bootstrap.c assembly.c rom.c
|
#
|
||||||
|
# MACHINE_SRCS is the machine itself, and both front ends link all of it. What separates
|
||||||
|
# SplitBit from Voyager is one file each: a terminal or a window. Anything that drifts out
|
||||||
|
# of the shared list and into one of those is behaviour the other does not have, which is
|
||||||
|
# the thing this split exists to prevent.
|
||||||
|
MACHINE_SRCS = machine.c io.c controller.c utility.c cpu.c bootstrap.c assembly.c rom.c
|
||||||
|
EMU_SRCS = emulator.c $(MACHINE_SRCS)
|
||||||
|
VOY_SRCS = voyager.c $(MACHINE_SRCS)
|
||||||
ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c
|
ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c
|
||||||
DSK_SRCS = SplitDisk.c
|
DSK_SRCS = SplitDisk.c
|
||||||
LINT_SRCS = Linter.c
|
LINT_SRCS = Linter.c
|
||||||
|
|
||||||
EMU_OBJS = $(EMU_SRCS:%.c=$(OBJ_DIR)/%.o)
|
EMU_OBJS = $(EMU_SRCS:%.c=$(OBJ_DIR)/%.o)
|
||||||
|
VOY_OBJS = $(VOY_SRCS:%.c=$(OBJ_DIR)/%.o)
|
||||||
ASM_OBJS = $(ASM_SRCS:%.c=$(OBJ_DIR)/%.o)
|
ASM_OBJS = $(ASM_SRCS:%.c=$(OBJ_DIR)/%.o)
|
||||||
DSK_OBJS = $(DSK_SRCS:%.c=$(OBJ_DIR)/%.o)
|
DSK_OBJS = $(DSK_SRCS:%.c=$(OBJ_DIR)/%.o)
|
||||||
LINT_OBJS = $(LINT_SRCS:%.c=$(OBJ_DIR)/%.o) $(OBJ_DIR)/assembly.o
|
LINT_OBJS = $(LINT_SRCS:%.c=$(OBJ_DIR)/%.o) $(OBJ_DIR)/assembly.o
|
||||||
|
|
||||||
# Output binary names
|
# Output binary names
|
||||||
EMU_TARGET = SplitBit
|
EMU_TARGET = SplitBit
|
||||||
|
VOY_TARGET = Voyager
|
||||||
ASM_TARGET = Assembler
|
ASM_TARGET = Assembler
|
||||||
DSK_TARGET = SplitDisk
|
DSK_TARGET = SplitDisk
|
||||||
LINT_TARGET = SplitLint
|
LINT_TARGET = SplitLint
|
||||||
|
|
||||||
# Default target: build the emulator and its three host-side tools.
|
# ---- Whether this machine can build Voyager ----
|
||||||
all: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
#
|
||||||
|
# PROBED BY BUILDING SOMETHING, not by looking for a file. A header that is present with no
|
||||||
|
# library behind it, or a library that needs flags this does not pass, would both pass a
|
||||||
|
# file check and then fail at link time, which is a much worse way to find out. If this
|
||||||
|
# compiles and links, so will Voyager.
|
||||||
|
#
|
||||||
|
# pkg-config first because that is what a packaged Raylib provides, and a bare -lraylib
|
||||||
|
# after it because a Raylib built from source usually does not install one.
|
||||||
|
RAYLIB_CFLAGS := $(shell pkg-config --cflags raylib 2>/dev/null)
|
||||||
|
RAYLIB_LIBS := $(shell pkg-config --libs raylib 2>/dev/null)
|
||||||
|
ifeq ($(strip $(RAYLIB_LIBS)),)
|
||||||
|
RAYLIB_LIBS := -lraylib -lm
|
||||||
|
endif
|
||||||
|
HAVE_RAYLIB := $(shell printf '#include <raylib.h>\nint main(void){return (int)GetTime();}\n' \
|
||||||
|
| $(CC) -x c - -o /dev/null $(RAYLIB_CFLAGS) $(RAYLIB_LIBS) 2>/dev/null \
|
||||||
|
&& echo yes)
|
||||||
|
|
||||||
|
# Default target: the machine, its three host-side tools, and Voyager where it can be built.
|
||||||
|
#
|
||||||
|
# VOYAGER IS NOT IN THE HARD LIST. Everything below it - the assembler, the disk tool, the
|
||||||
|
# linter, the whole test suite - has to build on a machine with no graphics library at all,
|
||||||
|
# because a project about a small understandable CPU should not need OpenGL to run its
|
||||||
|
# tests. Where Raylib is missing, 'make' says so once and builds everything else.
|
||||||
|
TOOLS = $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
||||||
|
|
||||||
|
ifeq ($(HAVE_RAYLIB),yes)
|
||||||
|
all: $(TOOLS) $(VOY_TARGET)
|
||||||
|
else
|
||||||
|
all: $(TOOLS)
|
||||||
|
@echo "Raylib was not found, so Voyager was not built. Everything else is here."
|
||||||
|
endif
|
||||||
|
|
||||||
# ---- The ROM the machine wakes up in ----
|
# ---- The ROM the machine wakes up in ----
|
||||||
#
|
#
|
||||||
@@ -86,6 +125,15 @@ $(OBJ_DIR)/rom.o: $(SRC_DIR_EMU)/rom.c $(SRC_DIR_EMU)/rom.h
|
|||||||
$(EMU_TARGET): $(EMU_OBJS)
|
$(EMU_TARGET): $(EMU_OBJS)
|
||||||
$(CC) $(CFLAGS) -o $(EMU_TARGET) $(EMU_OBJS)
|
$(CC) $(CFLAGS) -o $(EMU_TARGET) $(EMU_OBJS)
|
||||||
|
|
||||||
|
# Voyager: the same machine with a screen and a speaker. Its own object for the front end,
|
||||||
|
# and the shared ones for everything that is actually the machine.
|
||||||
|
$(VOY_TARGET): $(VOY_OBJS)
|
||||||
|
$(CC) $(CFLAGS) -o $(VOY_TARGET) $(VOY_OBJS) $(RAYLIB_LIBS)
|
||||||
|
|
||||||
|
$(OBJ_DIR)/voyager.o: $(SRC_DIR_EMU)/voyager.c
|
||||||
|
mkdir -p $(OBJ_DIR)
|
||||||
|
$(CC) $(CFLAGS) $(POSIXFLAGS) $(RAYLIB_CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||||
|
|
||||||
# Assembler binary
|
# Assembler binary
|
||||||
$(ASM_TARGET): $(ASM_OBJS)
|
$(ASM_TARGET): $(ASM_OBJS)
|
||||||
$(CC) $(CFLAGS) -o $(ASM_TARGET) $(ASM_OBJS)
|
$(CC) $(CFLAGS) -o $(ASM_TARGET) $(ASM_OBJS)
|
||||||
@@ -129,19 +177,29 @@ $(OBJ_DIR)/%.o: $(SRC_DIR_ASM)/%.c
|
|||||||
# that did not reach far enough, which the ordinary -Os build never saw.
|
# that did not reach far enough, which the ordinary -Os build never saw.
|
||||||
STRICT = -std=c11 -pedantic -Wall -Wextra -Werror $(POSIXFLAGS)
|
STRICT = -std=c11 -pedantic -Wall -Wextra -Werror $(POSIXFLAGS)
|
||||||
|
|
||||||
|
#
|
||||||
|
# VOYAGER IS CHECKED SEPARATELY, and only where Raylib is. It includes raylib.h, so putting
|
||||||
|
# it in the loop below would make 'make test' fail on exactly the machines the two-binary
|
||||||
|
# split exists to support - and it would not be noticed here, where Raylib is installed.
|
||||||
strict:
|
strict:
|
||||||
@for source in $(SRC_DIR_EMU)/*.c $(SRC_DIR_ASM)/*.c $(SRC_DIR_DSK)/*.c $(SRC_DIR_LINT)/*.c; do \
|
@for source in $(SRC_DIR_EMU)/*.c $(SRC_DIR_ASM)/*.c $(SRC_DIR_DSK)/*.c $(SRC_DIR_LINT)/*.c; do \
|
||||||
|
case "$$source" in *voyager.c) continue ;; esac; \
|
||||||
$(CC) $(STRICT) -c $$source -o /dev/null || exit 1; \
|
$(CC) $(STRICT) -c $$source -o /dev/null || exit 1; \
|
||||||
done
|
done
|
||||||
|
ifeq ($(HAVE_RAYLIB),yes)
|
||||||
|
@$(CC) $(STRICT) $(RAYLIB_CFLAGS) -c $(SRC_DIR_EMU)/voyager.c -o /dev/null
|
||||||
|
endif
|
||||||
@echo "The sources build clean under -std=c11 -pedantic."
|
@echo "The sources build clean under -std=c11 -pedantic."
|
||||||
|
|
||||||
# Run the test suite against the programs in Programs/
|
# Run the test suite against the programs in Programs/
|
||||||
test: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) strict
|
test: all strict
|
||||||
@./Tests/lint.sh
|
@./Tests/lint.sh
|
||||||
@echo
|
@echo
|
||||||
@echo
|
@echo
|
||||||
@./Tests/run.sh
|
@./Tests/run.sh
|
||||||
@echo
|
@echo
|
||||||
|
@./Tests/voyager.sh
|
||||||
|
@echo
|
||||||
@./Tests/disk.sh
|
@./Tests/disk.sh
|
||||||
@echo
|
@echo
|
||||||
@./Tests/cycles.sh
|
@./Tests/cycles.sh
|
||||||
@@ -183,6 +241,8 @@ sanitize:
|
|||||||
@echo
|
@echo
|
||||||
@./Tests/run.sh
|
@./Tests/run.sh
|
||||||
@echo
|
@echo
|
||||||
|
@./Tests/voyager.sh
|
||||||
|
@echo
|
||||||
@./Tests/disk.sh
|
@./Tests/disk.sh
|
||||||
@echo
|
@echo
|
||||||
@./Tests/cycles.sh
|
@./Tests/cycles.sh
|
||||||
@@ -208,7 +268,7 @@ clean:
|
|||||||
rm -rf $(OBJ_DIR)
|
rm -rf $(OBJ_DIR)
|
||||||
rm -rf Tests/build
|
rm -rf Tests/build
|
||||||
rm -f $(SRC_DIR_EMU)/rom.c
|
rm -f $(SRC_DIR_EMU)/rom.c
|
||||||
rm -f $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
rm -f $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) $(VOY_TARGET)
|
||||||
|
|
||||||
# Install compiled binaries
|
# Install compiled binaries
|
||||||
install: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
install: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
||||||
|
|||||||
Reference in New Issue
Block a user