Files
SplitBit-Emulator/Source/Emulator/machine.c
T
AnachronautandClaude Opus 5 773b0f8add Put CosmOS on the screen without changing a line of it
The console is now a display controller as well as a port: it owns a font, keeps a cursor,
handles newline, carriage return, backspace and wrapping, and scrolls. That is an ordinary
kind of chip - it is what a video terminal's character generator did - and it is the reason
this rung needed no changes to CosmOS at all. CosmOS already writes bytes to port 0x00.

It writes to BOTH the screen and standard output, which is deliberate. A machine with a
screen and a serial line is an ordinary machine, the emulator's standard output is that
serial line, and one console drives both. It is also what keeps all 165 recorded results
passing under Voyager, and what makes --screen work on the plain SplitBit: there is one
console and it drives everything it has.

Scrolling moves the video device's origin and no memory. The row arriving at the bottom is
cleared because the map is a ring and it holds what was there 128 rows ago; the rows going
off the top are not, and that is a hundred rows of scrollback nothing had to keep. The test
reads the register back rather than looking at the screen, because a console blitting rows
instead would look identical and cost twelve percent of a frame for every line printed.

The font is vendored from Hatchet-GPU with a note saying where it came from, since that
repository is not part of this one. 135 glyphs in ASCII order, which is the thing that makes
it worth keeping - PETSCII's whole inconvenience was that its order was not ASCII's, so a
machine using it needed a translation table in front of every string. Here the machine
subtracts 32. It is stored one bit a pixel and expanded into tile memory at reset: 1,088
bytes against 16 kilobytes.

Voyager gets a keyboard. A window has no standard input, and a machine blocking on it inside
a frame would stop drawing and stop answering, so a front end with a window installs a hook
that the console calls while it has nothing: it keeps the window alive and hands back a key.
The hook has to tell "nobody has typed yet", which happens sixty times a second, apart from
"the window has gone", which is the end of input - one value for both would have made the
first keystroke look like a closed machine. In line mode the console echoes what it is
given, because there is no terminal behind a window to do it and that was always the
terminal's job.

Tests/video.sh grew from 14 checks to 26, half of them about the console rather than the
device: those programs ask the video device for nothing and write bytes to port 0x00 like
every SplitBit program always has. Verified by breaking two things - removing the scroll
failed exactly the two checks about scrolling, and removing the cursor advance failed
exactly the three that depend on it.

Two video checks had quietly depended on palette entry 0 being black, which stopped being
true the moment a machine woke up able to show text. They now set what they are about to
look at, and a new check pins the waking state itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:26:27 -04:00

254 lines
11 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 "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 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();
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(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;
}
videoReset();
consoleHome();
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) {
// ---- 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);
}
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;
}