Files
SplitBit-Emulator/Source/Emulator/io.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

855 lines
34 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 "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 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);
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.
static int cursorColumn = 0;
static int cursorRow = 0;
void consoleHome(void) {
cursorColumn = 0;
cursorRow = 0;
}
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();
}
}
static void consoleDraw(uint8_t byte) {
switch (byte) {
case '\n':
consoleNewLine();
return;
case '\r':
cursorColumn = 0;
return;
case 0x08: // Backspace, which CosmOS sends when a line is being edited.
if (cursorColumn > 0) {
cursorColumn--;
videoPutCell(cursorRow, cursorColumn, 0, 0);
}
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), 0);
if (++cursorColumn >= videoColumns()) {
consoleNewLine();
}
}
// ---- 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)(void) = NULL;
void consoleSetInputHook(int (*hook)(void)) {
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) {
for (;;) {
int got = inputHook();
if (got >= 0) {
// ---- The screen is the terminal now ----
//
// In line mode a terminal echoes what is typed and rubs out a backspace,
// and CosmOS has always relied on that. There is no terminal behind a
// window, so the display controller does it - which is exactly the job a
// video terminal's character generator had. In key mode nothing echoes,
// because a program in key mode is drawing its own screen.
if (!consoleKeyMode) {
consoleDraw((uint8_t)got);
}
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; ask it again.
}
}
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();
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;
}
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;
}
// One bit per port, so a device can ask for attention without anything having to poll
// it. Eight ports to the byte, low bit first.
#define INTERRUPT_LINE_BYTES 32
static uint8_t pendingInterrupts[INTERRUPT_LINE_BYTES];
// Whether somebody has asked the machine to start over, and taking that request away.
static int resetWanted = 0;
int takeResetRequest(void) {
int wanted = resetWanted;
resetWanted = 0;
return wanted;
}
void raiseInterrupt(uint8_t port) {
pendingInterrupts[port >> 3] |= (uint8_t)(1u << (port & 7));
}
void clearInterrupt(uint8_t port) {
pendingInterrupts[port >> 3] &= (uint8_t)~(1u << (port & 7));
}
int nextPendingInterrupt(void) {
// 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 (pendingInterrupts[group] == 0) {
continue;
}
for (int bit = 0; bit < 8; bit++) {
if (pendingInterrupts[group] & (1u << bit)) {
return group * 8 + bit;
}
}
}
return -1;
}
// ---- 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 unsigned long deviceNow = 0;
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;
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_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);
}
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 controllerWrite(DataByte, Address);
}
// And so does the screen.
if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) {
return videoWrite(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.
consoleDraw(DataByte);
putchar(DataByte);
break;
case CONSOLE_CONTROL: consoleSetControl(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 controllerRead(Address);
}
if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) {
return videoRead(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_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;
}
}