THE MACHINE SUPPLIES BLOCKS AND SAYS WHAT A DRIVE IS. It says nothing about filesystems, which is what leaves room for a system that would rather have its own - and is why the volatile bit is a fact about the hardware rather than a promise about SBFS. 0x26 what the selected drive is: bit 0, contents do not survive 0x27, 0x28 how many blocks it has --ram-disk N a drive of N blocks with memory behind it A drive of memory selects, reads, writes and has a size like any other, and a program cannot tell the difference except by how fast it was. The one thing it cannot work out for itself is that the contents are volatile, because an empty disk and a volatile disk look identical from outside. THAT BIT IS THE DIFFERENCE BETWEEN A DRIVE A SYSTEM MAY FORMAT ON SIGHT AND ONE IT MUST NOT. CosmOS formats a volatile drive it cannot read, because there was never anything on it to lose, and leaves every other unreadable drive alone - an unformatted floppy is not an invitation, it is a blank floppy. Removing that check formats somebody's blank disk, which is checked rather than asserted: cosmosBlankDisk boots with one and requires it to be refused. So CosmOS grew a format. The size comes from the drive rather than from a superblock, since a superblock states a size too and that is no use on a disk which has not got one yet. Sixteen directory blocks, 128 names, chosen rather than worked out: a scratch disk runs out of names long before room, and this machine cannot divide. The RAM disk is no faster on this emulator by default, and that is honest rather than disappointing: the emulated disk has no seek time unless asked for one. With --disk-cycles 10000 the same copy is 7.94M cycles against 8.70M, the difference being every write. run.sh takes "ram:2048" where an image name goes, which needs no removing between runs because there is nothing to remove. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
360 lines
15 KiB
C
360 lines
15 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 "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();
|
|
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();
|
|
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);
|
|
}
|
|
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;
|
|
}
|