Naming a pad file that is not there printed the error and then said the machine had STARTED. MACHINE_OK is nought and the code returned nought, so the front end ran a machine whose clock had never been set up and divided by it: a typo in a path came out as a floating point exception and a core dump. The trap is two functions in one file with opposite conventions - machineStart returns MACHINE_OK for worked, machineRestart thirty lines up returns 1 for worked - and this copied the nearer one. Both of the returns I added last week had it. Checked now for all three files the replay suite is about, because the same mistake fits all of them, and re-broken to be sure: the check comes back exit 136, which is a signal 8, which is the crash. Found by somebody typing a path that was not there, which is the fourth thing this week that no test would have reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
396 lines
17 KiB
C
396 lines
17 KiB
C
// 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 "pad.h"
|
|
#include "video.h"
|
|
#include "sound.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);
|
|
}
|
|
}
|
|
|
|
|
|
// ---- A keyboard made of a file ----
|
|
//
|
|
// THE CONSOLE BEHIND A WINDOW IS NOT THE CONSOLE BEHIND A TERMINAL, and until this existed
|
|
// the difference was untestable. A terminal does the line editing; a window has none, so the
|
|
// console does it itself - gathering a line, rubbing out a backspace, handing it over only
|
|
// when Return arrives. That is real logic, it broke twice in two days, and both times it was
|
|
// found by a person typing rather than by anything here.
|
|
//
|
|
// So a file can be a keyboard. It installs the same hook a window does, which means the same
|
|
// path runs, and the suite can check what happens when a backspace arrives with nobody to
|
|
// interpret it. It does not test the window - Voyager's own key queue is still beyond reach
|
|
// - but it tests the console, which is where the logic is.
|
|
static FILE *keyboardFile = NULL;
|
|
|
|
static int keyboardHook(int mayWait) {
|
|
(void)mayWait; // There is no window to keep alive, so both questions are the same.
|
|
if (keyboardFile == NULL) {
|
|
return CONSOLE_GONE;
|
|
}
|
|
const int byte = fgetc(keyboardFile);
|
|
if (byte == EOF) {
|
|
return CONSOLE_GONE;
|
|
}
|
|
// ---- A zero is a moment of nobody typing ----
|
|
//
|
|
// The commonest thing that happens behind a window is NOTHING: sixty times a second the
|
|
// console asks and is told to come back later, and everything that goes on while that is
|
|
// true - the clock advancing, a cursor blinking, a disk finishing - was unreachable from
|
|
// here, because a file always has another byte. A zero is a byte no keyboard sends, so it
|
|
// is free to mean the one thing a file otherwise cannot say.
|
|
if (byte == 0x00) {
|
|
return CONSOLE_NOTHING_YET;
|
|
}
|
|
return byte & 0xFF;
|
|
}
|
|
|
|
// ---- Starting over ----
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
static int machineRestart(Machine *m) {
|
|
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 0;
|
|
}
|
|
videoReset();
|
|
padReset();
|
|
soundReset();
|
|
timerReset();
|
|
consoleHome();
|
|
consoleResetInput();
|
|
// ---- And every line down ----
|
|
//
|
|
// The same reasoning that clears the vector table. A handler left behind would aim an
|
|
// interrupt into a program that is no longer running; a line left behind arrives at one
|
|
// that never asked the device for anything. The devices reset above take their own down,
|
|
// and this is the rest of them - the disk in particular, which is not unplugged by a
|
|
// reset and keeps whatever it was doing.
|
|
clearAllInterrupts();
|
|
initializeCPU(&m->cpu, Program, Data);
|
|
// A machine that had stopped is running again, which is the entire point of asking from
|
|
// outside: the interesting time to restart something is when it is not going anywhere.
|
|
m->limitReached = 0;
|
|
return 1;
|
|
}
|
|
|
|
int machineTakeReset(Machine *m) {
|
|
if (!takeResetRequest()) {
|
|
return 0;
|
|
}
|
|
return machineRestart(m);
|
|
}
|
|
|
|
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;
|
|
}
|
|
// Every drive named, in the order it was named. Write protection is the machine's rather
|
|
// than a drive's for now: a tab on one floppy and not another is a thing to add when
|
|
// somebody wants it, and pretending otherwise here would be a promise the option cannot
|
|
// keep.
|
|
for (int at = 0; at < options->diskCount; at++) {
|
|
if (attachDisk(options->disks[at], options->writeProtect)) {
|
|
return MACHINE_ERROR;
|
|
}
|
|
}
|
|
// After the images, so the drive numbers a command line asks for are the order it asks
|
|
// in. A disk made of memory is still a drive and still has to be brought up by whatever
|
|
// system is running; the machine only supplies the blocks.
|
|
if (options->ramDisk > 0 && attachRamDisk((uint32_t)options->ramDisk)) {
|
|
return MACHINE_ERROR;
|
|
}
|
|
// The screen starts blank, and starts blank again on a warm restart: video memory is
|
|
// the device's, and a reset that left last program's screen up would be a reset that
|
|
// did not happen.
|
|
videoReset();
|
|
padReset();
|
|
soundReset();
|
|
timerReset();
|
|
if (options->sound != NULL) {
|
|
soundKeepSamples();
|
|
}
|
|
consoleHome();
|
|
// 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(machineController(), Program, Data);
|
|
initializeCPU(&m->cpu, Program, Data);
|
|
if (m->options.debug) {
|
|
printRegisters(&m->cpu, Program, Data);
|
|
}
|
|
// ---- The pads, from files ----
|
|
//
|
|
// MACHINE_ERROR and not nought. Nought is MACHINE_OK here, so returning it after printing
|
|
// an error said the machine had started - and the front end then ran a machine whose
|
|
// clock had never been set up and divided by it. A missing file came out as a floating
|
|
// point exception and a core dump.
|
|
//
|
|
// The trap is that machineRestart, thirty lines up, returns 1 for worked and 0 for did
|
|
// not. Two functions in one file with opposite conventions, and this copied the nearer
|
|
// one.
|
|
//
|
|
// Opened here beside the keyboard because they are the same kind of thing: a recording
|
|
// standing in for a person, so that what a person would exercise is reachable from a
|
|
// suite. They are never closed, for the same reason the keyboard is not - the machine
|
|
// outlives the call and the host reclaims them when it stops.
|
|
for (int n = 0; n < options->padCount; n++) {
|
|
FILE *pad = fopen(options->pads[n], "rb");
|
|
if (pad == NULL) {
|
|
fprintf(stderr, "Error: Couldn't open pad file: %s\n", options->pads[n]);
|
|
return MACHINE_ERROR;
|
|
}
|
|
padFromFile(n, pad);
|
|
}
|
|
|
|
if (options->padRecord != NULL) {
|
|
FILE *pad = fopen(options->padRecord, "wb");
|
|
if (pad == NULL) {
|
|
fprintf(stderr, "Error: Couldn't write pad file: %s\n", options->padRecord);
|
|
return MACHINE_ERROR;
|
|
}
|
|
padRecordTo(pad);
|
|
}
|
|
|
|
if (options->keyboard != NULL) {
|
|
keyboardFile = fopen(options->keyboard, "rb");
|
|
if (keyboardFile == NULL) {
|
|
fprintf(stderr, "Error: Couldn't read the keyboard file: %s\n", options->keyboard);
|
|
return MACHINE_ERROR;
|
|
}
|
|
consoleSetInputHook(keyboardHook);
|
|
}
|
|
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 (machineTakeReset(m)) {
|
|
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) {
|
|
// ---- Saving the screen ----
|
|
//
|
|
// Written when the machine stops, and it is what makes the screen testable at all: a
|
|
// suite has no display, so the only way to check what was drawn is to be handed it. A
|
|
// picture out of a headless run is also the quickest way for a person to see what a
|
|
// program actually put on the screen without sitting and watching it happen.
|
|
if (m->options.screen != NULL) {
|
|
videoWriteImage(m->options.screen);
|
|
}
|
|
// Every sample the machine made, for the same reason a picture is saved: there is no
|
|
// speaker on a machine running tests, and a sound nothing can hear is a sound nothing
|
|
// can check.
|
|
if (m->options.sound != NULL) {
|
|
soundWriteSamples(m->options.sound);
|
|
}
|
|
if (keyboardFile != NULL) {
|
|
consoleSetInputHook(NULL);
|
|
fclose(keyboardFile);
|
|
keyboardFile = NULL;
|
|
}
|
|
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;
|
|
}
|