Files
SplitBit-Emulator/Source/Emulator/io.c
T
AnachronautandClaude Opus 5 d388cd3122 Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.

Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.

Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.

Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:

  - Only one voice had an oscillator switched on, so three of the four
    channels could not make a sound whatever was written to them.
  - That voice's oscillator arrived at full gain and every other one
    arrived at nothing, an asymmetry with no reason behind it.
  - A note with no sustain is silent but not over, so the obvious way to
    wait for a sound to finish waits for ever.

The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.

Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.

Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:17 -04:00

1176 lines
46 KiB
C

// io.c
// I/O for the SplitBit CPU Emulator
// Written by Anachronaut
// 10/16/2024
#include "io.h"
#include "../Assembler/assembly.h" // For the fault vector numbers.
#include "controller.h"
#include "video.h"
#include "sound.h"
#include "font.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <errno.h>
#include <termios.h>
#include <unistd.h>
#include <poll.h>
// ---- The console ----
//
// The console owns its own reading rather than going through getchar. stdio keeps a
// buffer, and the status port asks the operating system what is waiting; those two
// disagree the moment stdio has read ahead, and the status port would then swear nothing
// was there while a read returned instantly. One byte of pushback here is enough, because
// nothing needs to look further ahead than the byte it is about to take.
static int consoleKeyMode = 0;
static int cursorColumn = 0;
static int cursorRow = 0;
// What every cell the console draws is given. Zero is the pair the machine wakes up in,
// grey on black.
static uint8_t consoleAttribute = 0;
static int consoleCursorShown = 0;
// The device draws the cursor, so it has to be told where the console put it. Called
// wherever the cursor moves, which is every one of the few places that move it.
static void consoleCursorMoved(void) {
videoSetCursor(cursorRow, cursorColumn, consoleCursorShown);
}
static int consoleEnded = 0;
static int consolePushback = -1; // A byte already taken from the host, or -1.
static int consoleInterrupts = 0; // Whether an arriving byte puts the line up.
static struct termios consoleSavedTerminal;
static int consoleTerminalSaved = 0; // There is a copy of how the terminal was found.
static int consoleTerminalRaw = 0; // The terminal is currently in this machine's mode.
static int consoleGuardsInstalled = 0; // The handlers below are in place.
// Hands the terminal back exactly as it was found, WITHOUT forgetting what the program
// asked for. Separate from consoleRestore because the two are wanted in different places:
// a machine that is stopping wants both, and a machine that is being suspended wants only
// this, since it is going to carry on wanting keys when it is resumed.
static void consoleReleaseTerminal(void) {
if (consoleTerminalRaw) {
tcsetattr(STDIN_FILENO, TCSANOW, &consoleSavedTerminal);
consoleTerminalRaw = 0;
}
}
static void consoleTakeTerminal(void) {
if (consoleTerminalRaw || !consoleTerminalSaved) {
return;
}
struct termios raw = consoleSavedTerminal;
raw.c_lflag &= (tcflag_t)~(ICANON | ECHO);
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) {
consoleTerminalRaw = 1;
}
}
void consoleRestore(void) {
consoleReleaseTerminal();
consoleKeyMode = 0;
// Whatever the console was in the middle of asking for is withdrawn along with the
// mode. A line left standing here would be answered by whatever ran next, which had
// nothing to do with it and never asked to be interrupted.
consoleInterrupts = 0;
clearInterrupt(PORT_CONSOLE);
}
// ---- Giving the terminal back whatever happens ----
//
// A machine that stops in key mode and does not undo it leaves the shell that started it
// with no echo and no line editing, which is a far worse failure than anything the program
// was doing, and one the user has no obvious way to connect to this program.
//
// atexit covers stopping on purpose and nothing else. It does NOT run when a process is
// killed by a signal, so every way of dying that matters has to be caught and undone by
// hand. The list below is every signal whose default action ends the process and which can
// be caught at all - SIGKILL and SIGSTOP cannot, and nothing can be done about those.
//
// SIGHUP is on the list for a specific reason, learned the hard way: it is what arrives
// when the terminal or the session that started this machine goes away, which is exactly
// what happens when whatever launched it crashes. Handling INT and TERM and stopping there
// covers the polite endings and misses the one that actually leaves a broken terminal
// behind.
// Puts the terminal back and then dies the way it would have died anyway, so that whatever
// is waiting sees the signal it expected rather than a machine that exited quietly.
static void consoleFatalSignal(int signalNumber) {
consoleReleaseTerminal();
signal(signalNumber, SIG_DFL);
raise(signalNumber);
}
static void consoleContinueSignal(int signalNumber);
// Suspending is not dying, so the terminal goes back but the mode is remembered. Whoever
// gets the terminal next is entitled to find it as they left it, and this machine is
// entitled to have its keys again when it is resumed.
static void consoleStopSignal(int signalNumber) {
consoleReleaseTerminal();
signal(SIGCONT, consoleContinueSignal);
signal(signalNumber, SIG_DFL);
raise(signalNumber);
}
static void consoleContinueSignal(int signalNumber) {
(void)signalNumber;
signal(SIGTSTP, consoleStopSignal);
signal(SIGCONT, consoleContinueSignal);
if (consoleKeyMode) {
consoleTakeTerminal();
}
}
static void consoleInstallGuards(void) {
if (consoleGuardsInstalled) {
return;
}
consoleGuardsInstalled = 1;
// Installed on the first use of key mode rather than at startup, so a run that never
// asks for it installs nothing at all.
atexit(consoleRestore);
static const int fatal[] = {
SIGHUP, SIGINT, SIGQUIT, SIGILL, SIGABRT, SIGFPE,
SIGBUS, SIGSEGV, SIGPIPE, SIGALRM, SIGTERM
};
for (size_t i = 0; i < sizeof(fatal) / sizeof(fatal[0]); i++) {
signal(fatal[i], consoleFatalSignal);
}
signal(SIGTSTP, consoleStopSignal);
signal(SIGCONT, consoleContinueSignal);
}
static void consoleSetMode(int wantKeys) {
if (wantKeys == consoleKeyMode) {
return;
}
if (!wantKeys) {
consoleRestore();
return;
}
// Nothing to configure when input is not a terminal, but the mode is still recorded:
// a program asking the status port what mode it is in should be told what it asked
// for, whether or not there was a terminal to carry it out on.
consoleKeyMode = 1;
if (!isatty(STDIN_FILENO)) {
return;
}
if (!consoleTerminalSaved) {
if (tcgetattr(STDIN_FILENO, &consoleSavedTerminal) != 0) {
return;
}
consoleTerminalSaved = 1;
}
consoleInstallGuards();
consoleTakeTerminal();
}
// Puts the line up if the console has something to say and has been asked to say it.
// Called wherever news arrives and wherever a program declares it wants to hear news, so
// that enabling interrupts while a byte is already waiting is not a way to miss it.
static void consoleAnnounce(void) {
if (consoleInterrupts && (consolePushback >= 0 || consoleEnded)) {
raiseInterrupt(PORT_CONSOLE);
}
}
// The whole control port in one write. The two bits are independent, so both are read out
// of the byte and applied, and neither is inferred from the other.
static void consoleSetControl(uint8_t control) {
// The mode goes first because turning key mode off restores the terminal, and that
// withdraws any standing request along with it. Setting the interrupt bit afterwards
// means one write can ask for line mode and interrupts together, which is an ordinary
// thing to want and would otherwise be undone in the same breath as it was asked for.
consoleSetMode((control & CONSOLE_MODE_KEY) != 0);
consoleCursorShown = (control & CONSOLE_CONTROL_CURSOR) != 0;
consoleCursorMoved();
int wantInterrupts = (control & CONSOLE_CONTROL_INTERRUPT) != 0;
if (!wantInterrupts) {
// Asking to stop being interrupted takes down whatever was already asked for. A
// request that outlived the setting that made it would arrive at a program that
// had just said it did not want it.
clearInterrupt(PORT_CONSOLE);
}
consoleInterrupts = wantInterrupts;
consoleAnnounce();
}
// Everything already written is put where it can be seen before the machine asks the host
// anything. Standard output is line buffered on a terminal, so a prompt with no newline
// after it - "> " is exactly that, and exactly why this matters - would sit in the buffer
// while the machine waited for an answer to a question nobody had been shown.
//
// getchar used to do this by accident, because reading through stdio flushes the line
// buffered streams first. Reading with read() does not, so what was a side effect of the
// old way is done deliberately here.
static void consoleShowWhatIsWritten(void) {
fflush(stdout);
}
// ---- The console draws as well as it speaks ----
//
// THE VOYAGER'S CONSOLE IS A DISPLAY CONTROLLER: it takes a byte stream and puts glyphs on
// a screen, keeps a cursor, and scrolls. That is an ordinary kind of chip - it is what a
// video terminal's character generator did - and it is why CosmOS needs no changes at all
// to run in a window. It already writes bytes to the console.
//
// It also keeps writing to standard output, and that is deliberate rather than an
// oversight. A machine with a screen AND a serial line is completely ordinary, the emulator's
// standard output is that serial line, and having both is what lets the whole test suite
// hold Voyager to the same recorded results as SplitBit. It is also what makes --screen
// work on the plain machine: there is one console, and it drives everything it has.
void consoleHome(void) {
cursorColumn = 0;
cursorRow = 0;
consoleAttribute = 0;
consoleCursorShown = 0;
consoleCursorMoved();
}
static void consoleNewLine(void) {
cursorColumn = 0;
if (cursorRow + 1 < videoRows()) {
cursorRow++;
} else {
// At the bottom, the screen moves under the cursor rather than the cursor moving
// off the screen. One port write in the device, and no memory moves at all.
videoScrollUp();
}
}
// Clears the screen, or from the cursor to the end of it. Reached through the console's
// Command port rather than through an escape sequence: this machine talks to its devices in
// registers, and a screen it can address directly needs no protocol to reach it.
static void consoleClearScreen(int fromCursor) {
const int rows = videoRows();
const int columns = videoColumns();
for (int row = fromCursor ? cursorRow : 0; row < rows; row++) {
const int from = (fromCursor && row == cursorRow) ? cursorColumn : 0;
for (int column = from; column < columns; column++) {
videoPutCell(row, column, 0, 0);
}
}
}
// ---- Driving a terminal on the other end of the serial line ----
//
// The machine speaks registers. A HOST TERMINAL SPEAKS ANSI, and bridging to the host is the
// emulator's job - the same job it does reading standard input. So the escapes are GENERATED
// here, outbound, for the set this device chooses, rather than parsed inbound as though the
// machine were a terminal itself.
//
// That is the whole difference in shape. Parsing means accepting an open protocol somebody
// else defines and putting a state machine in the hardware. Generating means one device
// knowing how to talk to one kind of host, in one direction, for exactly the things it can
// be asked to do - and the set cannot grow behind our backs, because we are the ones saying
// it.
static void consoleTellTerminal(const char *sequence) {
for (const char *at = sequence; *at != '\0'; at++) {
putchar(*at);
}
}
// ---- Told once, and only when it matters ----
//
// A terminal cares where the cursor is at the moment something is about to be drawn there,
// not at the moment a register was written. Announcing on every register write sent two
// sequences for one move, because setting a row and a column is two writes. So a write only
// marks it, and the next character sends it.
static int cursorTold = 1;
static void consoleSayCursor(void) {
if (cursorTold) {
return;
}
cursorTold = 1;
// Room for the widest this can be, and then some. The compiler cannot see that a cursor
// is bounded by the screen, and a warning about a buffer is not worth being clever over.
char sequence[32];
snprintf(sequence, sizeof(sequence), "\033[%d;%dH", cursorRow + 1, cursorColumn + 1);
consoleTellTerminal(sequence);
}
static void consoleDraw(uint8_t byte) {
// ---- Nowhere to put a glyph ----
//
// A console is a display controller, and a display controller draws characters on a
// character screen. In bitmap mode there is not one - the memory it would write into is
// somebody's picture - so it draws nothing and says everything down the serial line
// instead, which is where it was always going as well.
//
// The alternative is what a machine with shared video memory really does, which is
// scribble. That is honest and useless: nobody can read the marks and they ruin the
// picture, and a program that has taken the screen has not stopped wanting to print.
if (videoTextRows() == 0) {
return;
}
switch (byte) {
case '\n':
consoleNewLine();
consoleCursorMoved();
return;
case '\r':
cursorColumn = 0;
consoleCursorMoved();
return;
case 0x08: // Backspace, which CosmOS sends when a line is being edited.
if (cursorColumn > 0) {
cursorColumn--;
videoPutCell(cursorRow, cursorColumn, 0, consoleAttribute);
consoleCursorMoved();
}
return;
default:
break;
}
// Anything below the font's first character has no glyph and no agreed meaning here.
// Drawing a box for it would put marks on the screen that nothing asked for.
if (byte < CONSOLE_FONT_FIRST) {
return;
}
videoPutCell(cursorRow, cursorColumn, (uint8_t)(byte - CONSOLE_FONT_FIRST),
consoleAttribute);
if (++cursorColumn >= videoColumns()) {
consoleNewLine();
}
consoleCursorMoved();
}
// ---- Waiting for a key when there is no terminal to wait on ----
//
// A window has no standard input, and a machine that blocked on it inside a frame would
// stop drawing and stop answering. So a front end with a window installs a hook: called
// while the console has nothing, it gets to keep the window alive and hands back a byte
// when one is typed, or -1 to say the window has gone.
static int (*inputHook)(int mayWait) = NULL;
// About one frame, which is how long a hook that presents takes to come back. It does not
// have to be exact - nothing is being measured, and the only thing downstream of it is a
// cursor blinking at somebody who is thinking about what to type.
#define CONSOLE_WAIT_CYCLES 16667
static unsigned long idleCycles = 0;
// The machine's clock as the devices last heard it. Kept here rather than passed about,
// because a device that has to know how long it has been waiting has to know what time it
// is now.
static unsigned long deviceNow = 0;
// A frame has gone by with nobody typing. TWO THINGS FOLLOW FROM THAT, and only doing the
// first is what left the cursor frozen: the CPU is told afterwards that it was stopped for a
// while, which is what idle cycles are - and the DEVICES are told now, because they are
// still running. A display controller blinking a cursor does not stop because the processor
// is waiting on a key, and neither does a disk finishing a read.
static void consoleWaited(void) {
idleCycles += CONSOLE_WAIT_CYCLES;
deviceTick(deviceNow + CONSOLE_WAIT_CYCLES);
}
unsigned long takeIdleCycles(void) {
const unsigned long taken = idleCycles;
idleCycles = 0;
return taken;
}
// ---- Line editing, which the terminal used to do ----
//
// A TERMINAL IN LINE MODE DOES NOT HAND A PROGRAM EVERY KEYSTROKE. It collects a line,
// rubs out a backspace, and delivers the finished thing when Return is pressed. CosmOS has
// always relied on that, and behind a window there is no terminal to do it - so the raw
// backspace reached the shell, which put 0x08 in its command buffer and then could not find
// a command by that name. Correcting a typo made the line unrecognisable while looking
// perfectly right on screen.
//
// So the console does it, because behind a window the console IS the terminal. In key mode
// it does not: a program in key mode asked for every keystroke as it happens, which is the
// whole point of key mode.
#define CONSOLE_LINE_BYTES 256
static unsigned char consoleLine[CONSOLE_LINE_BYTES];
static int consoleLineLength = 0;
static int consoleLineAt = 0;
// Collects until Return, echoing as it goes, and leaves the line to be handed out a byte at
// a time. Returns 0 if the window closed while it was waiting.
static int consoleGatherLine(void) {
consoleLineAt = 0;
consoleLineLength = 0;
for (;;) {
const int got = inputHook(1);
if (got == CONSOLE_GONE) {
return 0;
}
if (got < 0) {
// Nobody has typed yet, and the hook spent a frame keeping the window alive.
consoleWaited();
continue;
}
if (got == 0x08) {
// Nothing to rub out at the start of a line, and rubbing out past it would eat
// the prompt, which belongs to whoever printed it.
if (consoleLineLength > 0) {
consoleLineLength--;
consoleDraw(0x08);
}
continue;
}
if (got == '\n' || got == '\r') {
consoleLine[consoleLineLength++] = '\n';
consoleDraw('\n');
return 1;
}
// No room, or nothing a line is made of. A control byte that means something to a
// terminal means nothing here yet, and putting it in the line would only hand a
// program something it cannot use.
if (got < 0x20 || consoleLineLength >= CONSOLE_LINE_BYTES - 1) {
continue;
}
consoleLine[consoleLineLength++] = (unsigned char)got;
consoleDraw((uint8_t)got);
}
}
void consoleSetInputHook(int (*hook)(int mayWait)) {
inputHook = hook;
}
uint8_t consoleReadByte(void) {
// Taking the byte answers whatever the console was asking about, so the line comes
// down here as well as when the CPU acknowledges it. Otherwise a program that reads
// the data port with the Interrupt Flag down would be interrupted afterwards on
// behalf of a byte it already has, and find nothing waiting when it looked.
clearInterrupt(PORT_CONSOLE);
if (consolePushback >= 0) {
uint8_t byte = (uint8_t)consolePushback;
consolePushback = -1;
return byte;
}
consoleShowWhatIsWritten();
if (inputHook != NULL) {
if (!consoleKeyMode) {
// A line already gathered is handed out a byte at a time, which is what the
// program is asking for. Only when it runs out is another one collected.
if (consoleLineAt >= consoleLineLength && !consoleGatherLine()) {
consoleEnded = 1;
return 0xFF;
}
return consoleLine[consoleLineAt++];
}
for (;;) {
const int got = inputHook(1);
if (got >= 0) {
// Nothing echoes in key mode: a program that asked for every keystroke as it
// happens is drawing its own screen, and marks it did not make would be in
// the way.
return (uint8_t)got;
}
if (got == CONSOLE_GONE) {
// The window has closed, which is this machine's end of input the way a
// closed pipe is the other one's.
consoleEnded = 1;
return 0xFF;
}
// Nothing typed yet. The hook kept the window alive, which took a frame, and a
// frame of waiting is a frame of time passing.
consoleWaited();
}
}
unsigned char byte;
for (;;) {
ssize_t got = read(STDIN_FILENO, &byte, 1);
if (got == 1) {
return byte;
}
if (got == 0) {
// End of input. Still 0xFF, which is what getchar's EOF became when this was
// the only answer available, so nothing written against the old behaviour
// changes. The ENDED bit is the new way to know it was not a real byte.
consoleEnded = 1;
return 0xFF;
}
if (errno != EINTR) {
consoleEnded = 1;
return 0xFF;
}
// Interrupted before anything arrived, so ask again.
}
}
// Asking the host whether anything is waiting, and TAKING IT IF THERE IS. The byte goes
// into the pushback and the next read of the data port hands it over, so nothing is lost
// and no program can tell that it was fetched early.
//
// Fetching it early is what makes the answer worth having. The operating system will say a
// pipe is readable when what is waiting is the end of it, so asking without reading can
// only report that SOMETHING is there. Reading settles which: a byte, or the end. Without
// this, ENDED could not go up until a program had already read the 0xFF that stands for
// it, and every program would have to swallow one imaginary byte to find out there were
// none.
static void consoleFetch(void) {
if (consolePushback >= 0 || consoleEnded) {
return;
}
// Flushed here too. A program that draws something and then polls rather than reads is
// just as entitled to have the drawing appear, and it never reaches the read that
// would otherwise have flushed for it.
consoleShowWhatIsWritten();
// ---- Where a byte comes from when there is no standard input ----
//
// A window's keys arrive through the hook, and THE STATUS PORT HAS TO ASK IT TOO. It did
// not, so a program polling READY in a window was asking a standard input nobody was
// typing at: Snake saw no keys at all, and would suddenly see one if the terminal behind
// the window happened to be focused. Asked without waiting, because a poll is a poll -
// the front end presents a frame when the console genuinely blocks, not when it looks.
if (inputHook != NULL) {
if (!consoleKeyMode) {
// READY means there is a byte to be had, and in line mode there is one only
// while a gathered line is still being handed out. A poll must not take a key
// from under the gatherer, and half a line is not a line.
if (consoleLineAt < consoleLineLength) {
consolePushback = consoleLine[consoleLineAt++];
consoleAnnounce();
}
return;
}
const int got = inputHook(0);
if (got >= 0) {
consolePushback = got;
consoleAnnounce();
} else if (got == CONSOLE_GONE) {
consoleEnded = 1;
}
return;
}
struct pollfd waiting = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
if (poll(&waiting, 1, 0) <= 0 || (waiting.revents & (POLLIN | POLLHUP)) == 0) {
return;
}
unsigned char byte;
ssize_t got = read(STDIN_FILENO, &byte, 1);
if (got == 1) {
consolePushback = byte;
} else if (got == 0) {
consoleEnded = 1;
}
// A read that failed for any other reason is left alone: the next attempt asks again,
// and an interrupted poll is not news.
//
// Anything that was news puts the line up. This is the only place a byte arrives from
// the outside world, so it is the only place that has to, and it raises AT MOST ONCE
// PER BYTE for free: the pushback holds one, and while it is full there is nothing to
// fetch and so nothing to announce. A handler that does not read what it was called
// about is simply not called again, the way a receive register with one byte in it
// stops asking. The end of input announces itself once for the same reason - it is
// discovered once, and every later look leaves before it gets here.
consoleAnnounce();
}
// How many instructions the machine runs between glances at the console. Nothing here
// happens alongside the CPU, so noticing a keystroke costs a system call, and asking on
// every instruction costs more than executing one: a poll is about 150ns against roughly
// 9ns for an instruction at full tilt, so it would slow the machine by nearly twenty
// times. At the emulated clock this stride is a quarter of a millisecond between glances,
// which no one typing has ever been able to tell from immediately.
#define CONSOLE_SERVICE_STRIDE 256
void serviceDevices(void) {
// The common case is a machine nobody is interrupting, and it costs one test.
if (!consoleInterrupts) {
return;
}
static unsigned int untilNextGlance = 0;
if (untilNextGlance > 0) {
untilNextGlance--;
return;
}
untilNextGlance = CONSOLE_SERVICE_STRIDE - 1;
consoleFetch();
}
static uint8_t consoleStatus(void) {
uint8_t status = 0;
if (consoleKeyMode) {
status |= CONSOLE_STATUS_KEYMODE;
}
if (consoleInterrupts) {
status |= CONSOLE_STATUS_INTERRUPT;
}
if (consoleCursorShown) {
status |= CONSOLE_STATUS_CURSOR;
}
consoleFetch();
if (consoleEnded) {
// READY IS NOT SET HERE, although a read would answer immediately. The bit means
// "there is a byte to be had", and at the end of input there is not; what a read
// returns then is 0xFF standing in for nothing. A program looping while READY
// stops on its own at the end, which is the behaviour worth having, and one that
// wants to know why asks ENDED.
return status | CONSOLE_STATUS_ENDED;
}
if (consolePushback >= 0) {
status |= CONSOLE_STATUS_READY;
}
return status;
}
// The machine's own lines. A peripheral core's device keeps its own set, which is the
// entire reason these are a struct rather than an array sitting here.
static InterruptLines machineLines;
// And its own controller, for the same reason. Everything on this bus that moves memory means
// this one.
static Controller theMachinesController;
Controller *machineController(void) {
return &theMachinesController;
}
static uint8_t machineControllerWrite(uint8_t value, uint8_t port) {
return controllerWrite(&theMachinesController, value, port);
}
static uint8_t machineControllerRead(uint8_t port) {
return controllerRead(&theMachinesController, port);
}
static unsigned long machineControllerCycles(void) {
return controllerTakeCycles(&theMachinesController);
}
// Whether somebody has asked the machine to start over, and taking that request away.
static int resetWanted = 0;
void requestReset(void) {
resetWanted = 1;
}
void consoleResetInput(void) {
consolePushback = -1;
consoleEnded = 0;
consoleLineLength = 0;
consoleLineAt = 0;
clearInterrupt(PORT_CONSOLE);
}
int resetIsPending(void) {
return resetWanted;
}
int takeResetRequest(void) {
int wanted = resetWanted;
resetWanted = 0;
return wanted;
}
// ---- The bus this machine's processor is on ----
//
// Everything a CPU asks of the world outside it, for the world this file is. A peripheral
// core is handed a different one of these by whatever device contains it, which is the whole
// of what a private bus is: not a number to be checked, a different set of answers.
static const Bus theMachinesBus = {
OutputHandler,
InputHandler,
machineControllerCycles,
takeIdleCycles,
nextPendingInterrupt,
clearInterrupt,
};
const Bus *machineBus(void) {
return &theMachinesBus;
}
void linesRaise(InterruptLines *lines, uint8_t port) {
lines->bits[port >> 3] |= (uint8_t)(1u << (port & 7));
}
void linesClear(InterruptLines *lines, uint8_t port) {
lines->bits[port >> 3] &= (uint8_t)~(1u << (port & 7));
}
// The machine's own, which is what every device in this file means when it asks for
// attention. Wrappers rather than a change at every call site, because every one of those
// devices really is on this bus and saying so twenty times would not make it truer.
void raiseInterrupt(uint8_t port) {
linesRaise(&machineLines, port);
}
void clearInterrupt(uint8_t port) {
linesClear(&machineLines, port);
}
int linesNext(const InterruptLines *lines) {
// Lowest numbered port wins. This is a scan rather than a priority encoder, which
// means there is no arbitration to explain and a programmer can work out what
// happens next by reading the port numbers.
for (int group = 0; group < INTERRUPT_LINE_BYTES; group++) {
if (lines->bits[group] == 0) {
continue;
}
for (int bit = 0; bit < 8; bit++) {
if (lines->bits[group] & (1u << bit)) {
return group * 8 + bit;
}
}
}
return -1;
}
int nextPendingInterrupt(void) {
return linesNext(&machineLines);
}
// ---- Refusing ----
//
// Set when a device will not do what it was asked, and read by the CPU immediately
// after the instruction that asked. It is not a queue: an instruction does one thing to
// one port, so there is only ever one refusal outstanding.
static uint8_t refusedVector = 0;
static uint8_t refusedPort = 0;
void refuseAccess(uint8_t faultVector) {
refusedVector = faultVector;
}
uint8_t takeRefusal(void) {
uint8_t vector = refusedVector;
refusedVector = 0;
return vector;
}
uint8_t refusingPort(void) {
return refusedPort;
}
// ---- The disk ----
//
// A block device and nothing more. It knows numbered blocks and has never heard of a
// file, which is the whole point: a filesystem is software this machine will run, not
// something the host does on its behalf. A disk that understood filenames would be the
// emulator doing the work and the machine pretending it had.
static FILE *diskImage = NULL;
static uint32_t diskBlockCount = 0;
static uint8_t diskBuffer[DISK_BLOCK_BYTES];
// ---- A disk that takes time ----
//
// The command is checked at once, because a refusal is not work: asking for a block that
// is not there, or writing to a protected disk, fails before any head moves. What takes
// time is the transfer, so that is remembered here and done when the machine has run far
// enough - and until then the buffer holds the block BEFORE this one, which is exactly
// what a program that ignores the busy bit deserves to read.
// The machine's clock as devices see it, which the emulator advances as the CPU spends
// cycles. A device says when it will be finished in these, and is believed.
static void diskTransfer(uint8_t command);
static unsigned long diskLatency = 0;
static unsigned long diskReadyAt = 0;
static uint8_t diskPending = 0;
static uint16_t diskBlock = 0;
static uint8_t diskStatus = 0;
static uint8_t diskProtected = 0;
uint8_t attachDisk(const char *path, uint8_t writeProtect) {
diskProtected = writeProtect ? 1 : 0;
diskImage = fopen(path, "r+b");
if (diskImage == NULL) {
// It may be there and simply not writable, which is a read only disk rather than
// a missing one. Try that before deciding to make a new one.
diskImage = fopen(path, "rb");
if (diskImage != NULL) {
diskProtected = 1;
}
}
if (diskImage == NULL) {
// Nothing there, so make one. A fresh image is zeroes, which is what an unwritten
// block should read as.
diskImage = fopen(path, "w+b");
if (diskImage == NULL) {
fprintf(stderr, "Error: Couldn't open or create the disk image: %s\n", path);
return 1;
}
static const uint8_t empty[DISK_BLOCK_BYTES] = {0};
for (uint32_t i = 0; i < DISK_DEFAULT_BLOCKS; i++) {
if (fwrite(empty, 1, DISK_BLOCK_BYTES, diskImage) != DISK_BLOCK_BYTES) {
fprintf(stderr, "Error: Couldn't write the disk image: %s\n", path);
fclose(diskImage);
diskImage = NULL;
return 1;
}
}
}
if (fseek(diskImage, 0, SEEK_END) != 0) {
fprintf(stderr, "Error: Couldn't measure the disk image: %s\n", path);
fclose(diskImage);
diskImage = NULL;
return 1;
}
long size = ftell(diskImage);
// A part written block at the end is not a block, so it is not counted.
diskBlockCount = (size > 0) ? (uint32_t)(size / DISK_BLOCK_BYTES) : 0;
// The protect bit is a standing property, so it reads true before anything has been
// asked of the disk rather than only after a write has been turned away.
diskStatus = diskProtected ? DISK_STATUS_PROTECTED : 0;
return 0;
}
void detachDisk(void) {
if (diskImage != NULL) {
fclose(diskImage);
diskImage = NULL;
}
}
// Reads or writes the block the block registers name. The line goes up either way: the
// operation finished, and whether it worked is what Status is for.
static void diskCommand(uint8_t command) {
// The protect bit describes the disk rather than the operation, so it survives.
diskStatus = diskProtected ? DISK_STATUS_PROTECTED : 0;
if (command == DISK_COMMAND_WRITE && diskProtected) {
diskStatus |= DISK_STATUS_ERROR;
raiseInterrupt(PORT_DISK);
return;
}
if (diskImage == NULL || diskBlock >= diskBlockCount) {
diskStatus |= DISK_STATUS_ERROR;
raiseInterrupt(PORT_DISK);
return;
}
long offset = (long)diskBlock * DISK_BLOCK_BYTES;
if (fseek(diskImage, offset, SEEK_SET) != 0) {
diskStatus |= DISK_STATUS_ERROR;
raiseInterrupt(PORT_DISK);
return;
}
if (command != DISK_COMMAND_READ && command != DISK_COMMAND_WRITE) {
diskStatus |= DISK_STATUS_ERROR;
raiseInterrupt(PORT_DISK);
return;
}
if (diskLatency == 0) {
diskTransfer(command);
return;
}
// It is going to take a while. Say so, and remember what to do when it is over.
diskStatus |= DISK_STATUS_BUSY;
diskPending = command;
diskReadyAt = deviceNow + diskLatency;
}
// The transfer itself, whenever it happens to happen. The seek is done here rather than at
// the command, because nothing else may touch the image in between and doing it twice is
// the same answer.
static void diskTransfer(uint8_t command) {
size_t moved = 0;
long offset = (long)diskBlock * DISK_BLOCK_BYTES;
if (fseek(diskImage, offset, SEEK_SET) != 0) {
diskStatus |= DISK_STATUS_ERROR;
} else if (command == DISK_COMMAND_READ) {
moved = fread(diskBuffer, 1, DISK_BLOCK_BYTES, diskImage);
} else {
moved = fwrite(diskBuffer, 1, DISK_BLOCK_BYTES, diskImage);
fflush(diskImage);
}
if (moved != DISK_BLOCK_BYTES) {
diskStatus |= DISK_STATUS_ERROR;
}
diskStatus &= (uint8_t)~DISK_STATUS_BUSY;
raiseInterrupt(PORT_DISK);
}
void setDiskLatency(unsigned long cycles) {
diskLatency = cycles;
}
void deviceTick(unsigned long now) {
deviceNow = now;
// The screen blinks its cursor on the machine's own clock rather than the host's, so the
// picture is the same at the same cycle count however fast anything ran.
videoTick(now);
// And the sound, which makes whatever samples are due by now. On the machine's clock,
// so the same program makes the same sound in the same cycles.
soundTick(now);
if (diskPending && now >= diskReadyAt) {
uint8_t command = diskPending;
diskPending = 0;
diskTransfer(command);
}
}
// ---- A device that brings memory ----
//
// The simplest thing that owns a bank. Writing to its port fills its memory with the
// byte written, which stands in for a disk controller reading a sector: the CPU asks for
// something and the memory it owns then holds the answer. The waiting is taken out so a
// test runs the same way every time.
#define DEVICE_MEMORY_BYTES 256
static uint8_t deviceMemoryBlock[DEVICE_MEMORY_BYTES];
uint8_t *deviceMemory(uint8_t port, uint32_t *capacity) {
if (port == PORT_MEMORY) {
*capacity = DEVICE_MEMORY_BYTES;
return deviceMemoryBlock;
}
if (port == PORT_DISK) {
// The disk's buffer is one block. Reading fills it and writing takes what is in
// it, and the only way to reach it is to register it as a bank and go through the
// controller.
*capacity = DISK_BLOCK_BYTES;
return diskBuffer;
}
if (port == PORT_VIDEO) {
// Tiles, the map and the palette, in one bank. A program blits the part that
// changed and the rest stays as it was, which is the whole reason the screen is a
// bank rather than a window onto a port.
return videoMemory(capacity);
}
return NULL;
}
// ---- The bus registry ----
//
// What is plugged into this machine. The table is fixed when the machine is built: a
// program cannot write to it, because writing would only let a program lie to itself
// about what hardware exists. Which routine handles a device is a different question,
// and the vector table already answers it.
//
// Nothing here touches the device being asked about. That matters more than it looks:
// reading a port is a real operation, and asking the console what it is by reading it
// would take a character off standard input and block waiting for one.
typedef struct {
uint8_t port;
uint8_t deviceClass;
uint8_t flags;
} DeviceRecord;
static const DeviceRecord deviceTable[] = {
{ PORT_CONSOLE, DEVICE_CONSOLE, 0 },
{ PORT_TEST, DEVICE_TEST, 0 },
{ PORT_REFUSE, DEVICE_REFUSE, 0 },
{ PORT_MACHINE, DEVICE_MACHINE, 0 },
{ PORT_MEMORY, DEVICE_MEMORY, DEVICE_FLAG_HAS_MEMORY },
{ PORT_DISK, DEVICE_DISK, DEVICE_FLAG_HAS_MEMORY },
{ PORT_VIDEO, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY },
{ PORT_SOUND, DEVICE_SOUND, 0 },
{ PORT_REGISTRY, DEVICE_REGISTRY, 0 },
};
static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]));
// Which port the registry is currently being asked about, and how far through that
// port's record it has been read. Selecting a port starts the record again.
static uint8_t registrySelected = 0;
static uint8_t registryCursor = 0;
static const DeviceRecord controllerRecord = { CONTROLLER_PORT_BASE, DEVICE_CONTROLLER, 0 };
static const DeviceRecord *deviceOnPort(uint8_t port) {
// The controller answers on a block of ports rather than one, so every port in the
// block reports it. Its own memory is bank 2, which is already registered, so it
// does not set the flag that means "this brings memory somebody has to register".
if (port >= CONTROLLER_PORT_BASE && port <= CONTROLLER_PORT_TOP) {
return &controllerRecord;
}
if (port > PORT_CONSOLE && port <= PORT_CONSOLE_TOP) {
// The status and control ports are the same device as the data port, which is the
// one in the table and the one the console raises its line on.
return deviceOnPort(PORT_CONSOLE);
}
if (port > PORT_DISK && port <= PORT_DISK_TOP) {
// The base port is in the table proper, since that is the one that owns the
// memory and raises the line. The rest of the block reports the same device.
return deviceOnPort(PORT_DISK);
}
if (port > PORT_VIDEO && port <= PORT_VIDEO_TOP) {
// Sixteen ports, one device, and the same rule again.
return deviceOnPort(PORT_VIDEO);
}
if (port > PORT_SOUND && port <= PORT_SOUND_TOP) {
return deviceOnPort(PORT_SOUND);
}
for (int i = 0; i < deviceCount; i++) {
if (deviceTable[i].port == port) {
return &deviceTable[i];
}
}
return NULL;
}
// One byte of the selected port's record. Everything about a port that is not there
// reads as zero, which is the same answer an absent registry would give.
static uint8_t readRegistry(void) {
const DeviceRecord *device = deviceOnPort(registrySelected);
uint8_t answer = 0;
if (device != NULL && registryCursor < DEVICE_RECORD_BYTES) {
answer = (registryCursor == 0) ? device->deviceClass : device->flags;
}
if (registryCursor < DEVICE_RECORD_BYTES) {
registryCursor++;
}
return answer;
}
uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
// Whichever port is being talked to is the one that would be doing any refusing.
refusedPort = Address;
// The controller answers on a block of ports, which is a range rather than a list.
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
return machineControllerWrite(DataByte, Address);
}
// And so does the screen.
if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) {
return videoWrite(DataByte, Address);
}
if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) {
return soundWrite(DataByte, Address);
}
// This function sends the DataByte to the appropriate place based on the Port Address.
switch(Address) {
case CONSOLE_DATA:
// Both, always. The screen because this machine has one, and standard output
// because the serial line is how everything that is not a person reads it.
//
// The terminal is told where the cursor went first, if it has not been told
// since it was moved. Here rather than at the move, so setting a row and a
// column costs one sequence rather than two.
consoleSayCursor();
consoleDraw(DataByte);
putchar(DataByte);
break;
case CONSOLE_CONTROL: consoleSetControl(DataByte); break;
case CONSOLE_CURSOR_ROW:
// Clamped rather than refused. A cursor asked to go off the screen has an
// obvious place to be, and stopping the machine over one is a poor trade.
cursorRow = DataByte < videoRows() ? DataByte : videoRows() - 1;
cursorTold = 0;
consoleCursorMoved();
break;
case CONSOLE_CURSOR_COLUMN:
cursorColumn = DataByte < videoColumns() ? DataByte : videoColumns() - 1;
cursorTold = 0;
consoleCursorMoved();
break;
case CONSOLE_COMMAND:
if (DataByte == CONSOLE_COMMAND_CLEAR) {
consoleClearScreen(0);
consoleTellTerminal("\033[2J");
}
// Anything else does nothing. A command block reserved for later should be
// quiet rather than fatal, the same as the screen's spare registers.
break;
case CONSOLE_ATTRIBUTE: consoleAttribute = DataByte; break;
case CONSOLE_STATUS:
// Read only. A device saying how it is does not take instructions through the
// same hole, so a write here is ignored rather than meaning something.
break;
case DISK_BLOCK_HIGH: diskBlock = (uint16_t)(DataByte << 8) | (diskBlock & 0x00FF); break;
case DISK_BLOCK_LOW: diskBlock = (diskBlock & 0xFF00) | DataByte; break;
case DISK_COMMAND: diskCommand(DataByte); break;
case PORT_MACHINE:
// Asked for here and acted on between instructions, because a device cannot
// restart the machine from inside the instruction that asked: the CPU is part
// way through a step and its state is not yet anything a reset could leave
// consistently behind.
if (DataByte == MACHINE_RESET) {
resetWanted = 1;
}
break;
case PORT_MEMORY:
// Fills the memory this device owns with the byte written. Nothing is
// reachable from here: to get at it, register it as a bank and go through
// the controller, which is the only thing that can reach a device's memory.
memset(deviceMemoryBlock, DataByte, DEVICE_MEMORY_BYTES);
break;
case PORT_REFUSE:
// A device that refuses everything. It exists so that a device's ability to
// stop the CPU can be tested before anything depends on it, and so that the
// path stays tested once the memory controller is the only real user.
refuseAccess(VECTOR_GUARD_VIOLATION);
break;
case PORT_REGISTRY:
// Names the port the registry is being asked about. This is the only thing
// that can be written to the registry, and it changes nothing about the
// machine: it selects a question, it does not give an answer.
registrySelected = DataByte;
registryCursor = 0;
break;
case PORT_TEST:
// A test device, and about the simplest one that can exist: writing to it
// puts its own line up. It stands in for the shape a real device has, where
// the CPU asks for something and is interrupted once the answer is ready,
// with the waiting taken out so that a test runs the same way every time.
// The byte written is ignored; only the asking matters.
raiseInterrupt(PORT_TEST);
break;
default:
// Writes to unused Output Ports are ignored.
return 1;
break;
}
return 0;
}
uint8_t InputHandler(uint8_t Address) {
refusedPort = Address;
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
return machineControllerRead(Address);
}
if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) {
return videoRead(Address);
}
if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) {
return soundRead(Address);
}
switch(Address) {
case CONSOLE_DATA:
// If data is sent here, it should be read from STDIN.
return consoleReadByte();
break;
case CONSOLE_STATUS: return consoleStatus();
case CONSOLE_CURSOR_ROW: return (uint8_t)cursorRow;
case CONSOLE_CURSOR_COLUMN: return (uint8_t)cursorColumn;
case CONSOLE_ATTRIBUTE: return consoleAttribute;
case CONSOLE_COMMAND:
// Write only. What it did is visible in the cursor and on the screen.
return 0;
break;
case CONSOLE_CONTROL:
// Write only. Reading it gives zero rather than what was last written, because
// everything it sets is reported by the status port and one fact wants one
// place to live.
return 0;
break;
case DISK_BLOCK_HIGH: return (uint8_t)(diskBlock >> 8);
case DISK_BLOCK_LOW: return (uint8_t)(diskBlock & 0xFF);
case DISK_STATUS: return diskStatus;
case PORT_REFUSE:
// Refuses reads as well, so both directions are covered.
refuseAccess(VECTOR_GUARD_VIOLATION);
return 0;
break;
case PORT_REGISTRY:
// One byte of the selected port's record, then the next, and zero once the
// record has run out.
return readRegistry();
break;
default:
// Reading from an unused port is ignored.
return 0;
break;
}
}