A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way the disk's buffer is. The CPU writes cell indices and the device turns them into pixels, which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles, a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that changes two cells writes four bytes. The cost of a screen becomes the number of cells that changed rather than the number of pixels on it. Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and each picks independently out of 256 colours, with no per-cell limit of the kind that made a Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the nibble at zero and gets them. Neither use costs the other anything. Two decisions are arithmetic rather than taste, and both come from the machine having no multiply. A map row is a page whether the mode fills it or not, so a cell address is the row number as the high byte and the doubled column as the low byte with no arithmetic at all; otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And a palette entry is four bytes rather than three, so entry n is at n times four, a shift. THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is twelve percent of a frame even with the controller widened, and a program printing one page would spend six frames shuffling memory. It is now one port write - and the rows that scrolled off are still there, which is where a terminal gets scrollback it never had. The device is part of the machine rather than part of the window. It renders into a buffer that is a pure function of video memory, so the same program draws the same picture with nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries take --screen, which saves a PPM when the machine stops, and that is what makes a screen checkable on a host with no display at all. Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image, because a recorded image would say "something changed" and leave which of the palette, the tile, the attribute, the map or the scroll register broke to be found by hand. Verified by breaking three things in turn: the additive nibble failed exactly one check, the scroll origin exactly two, and moving every cell one pixel sideways exactly the four about placement. Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported itself as wrong for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
288 lines
14 KiB
C
288 lines
14 KiB
C
// io.h
|
|
// I/O for the SplitBit CPU Emulator
|
|
// Written by Anachronaut
|
|
// 10/16/2024
|
|
|
|
#ifndef IO_H
|
|
#define IO_H
|
|
|
|
#include <stdint.h>
|
|
#include "cpu.h"
|
|
|
|
// ---- Ports ----
|
|
//
|
|
// Which port a device answers on is a property of the machine rather than of any
|
|
// program, so the numbers live here and everything else refers to them by name.
|
|
|
|
// The console answers on three ports. The data port is the machine's oldest promise and
|
|
// does not change: writing sends a byte, reading takes one and waits for it. The other two
|
|
// are additions, so a program written before they existed cannot notice them.
|
|
#define PORT_CONSOLE 0x00
|
|
#define PORT_CONSOLE_TOP 0x02
|
|
#define CONSOLE_DATA 0x00
|
|
#define CONSOLE_STATUS 0x01
|
|
#define CONSOLE_CONTROL 0x02
|
|
|
|
#define PORT_TEST 0x10
|
|
#define PORT_REFUSE 0x11
|
|
#define PORT_MEMORY 0x12
|
|
|
|
// ---- Starting again ----
|
|
//
|
|
// Writing 1 here asks the machine to start over: whatever put the first instruction in
|
|
// memory does it again, and the CPU begins where the boot vector points.
|
|
//
|
|
// A PORT RATHER THAN A SERVICE, because a reset has to work when the system does not.
|
|
// Something that could only be asked for through SWI would be unavailable in exactly the
|
|
// case that most wants it, and a program that owns the whole machine has no system to ask.
|
|
//
|
|
// What it does NOT do is unplug anything. The disk stays attached and its image keeps
|
|
// whatever was written to it, which is what a warm restart means: the machine starts
|
|
// again, the world it starts into does not.
|
|
#define PORT_MACHINE 0x13
|
|
#define MACHINE_RESET 0x01
|
|
|
|
// The disk answers on a block of four ports and interrupts on the first of them. A device
|
|
// that spans more than one port raises its line on its base, which is the rule the
|
|
// machine has not needed until now: the controller spans sixteen and never interrupts.
|
|
#define PORT_DISK 0x20
|
|
#define PORT_DISK_TOP 0x23
|
|
#define DISK_BLOCK_HIGH 0x20
|
|
#define DISK_BLOCK_LOW 0x21
|
|
#define DISK_COMMAND 0x22
|
|
#define DISK_STATUS 0x23
|
|
// ---- The screen ----
|
|
//
|
|
// Sixteen ports, like the controller, and it interrupts on its base the way the disk
|
|
// established for a device that spans more than one. The registers themselves are in
|
|
// video.h, with the memory layout they describe.
|
|
#define PORT_VIDEO 0x30
|
|
#define PORT_VIDEO_TOP 0x3F
|
|
|
|
#define PORT_REGISTRY 0xFF
|
|
|
|
// ---- The console ----
|
|
//
|
|
// Two modes, chosen by the program through the control port. The console starts in LINE
|
|
// mode, which is what the machine has always done: the terminal holds what is typed until
|
|
// Return, and does the echoing and the backspacing on the way. Reading the data port waits
|
|
// for a whole line to be finished somewhere else and then hands it over a byte at a time.
|
|
//
|
|
// KEY mode turns that off. Keys arrive as they are pressed, and nothing echoes them, so a
|
|
// program that wants them seen has to send them back out itself. That is not a choice this
|
|
// machine is making; it is what asking the terminal to stop holding a line means, and the
|
|
// editing goes away with it. A program that wants keys is expected to want that.
|
|
//
|
|
// READING THE DATA PORT WAITS IN BOTH MODES. The status port is how a program declines to
|
|
// wait, and keeping that in one place means the data port means one thing everywhere. A
|
|
// read that sometimes blocked and sometimes did not, depending on state set somewhere
|
|
// else, is the kind of thing that works until it does not.
|
|
//
|
|
// KEY MODE ONLY REACHES THE TERMINAL when there is one. With input coming from a pipe
|
|
// there is nothing to put into another mode, and the status port answers by asking the
|
|
// operating system whether anything is waiting, which is true of a pipe with bytes in it.
|
|
//
|
|
// A PROGRAM THAT INTERRUPTS ON INPUT MUST NOT BLOCK ON THE DATA PORT. Reading it waits,
|
|
// and the machine executes no instructions while it is waiting, so nothing is serviced
|
|
// and the line the console is about to raise goes nowhere until the read it was meant to
|
|
// replace has already finished. Interrupting and blocking are two answers to the same
|
|
// question and a program wants one of them.
|
|
|
|
// The control port's bits, which are independent of one another. Writing zero asks for
|
|
// line mode with no interrupts, which is how the console starts and what a program that
|
|
// knows nothing of any of this leaves behind it.
|
|
#define CONSOLE_MODE_LINE 0x00
|
|
#define CONSOLE_MODE_KEY 0x01
|
|
|
|
// Asks the console to put its line up when a byte arrives, instead of the program having
|
|
// to come and look. It composes with the mode rather than depending on it: in line mode
|
|
// the terminal still holds what is typed until Return, and then a whole line's worth of
|
|
// bytes arrive at once, each raising the line in turn as the one before it is taken.
|
|
// That is not especially useful, but a control bit that quietly did nothing depending on
|
|
// another control bit would be worse than a burst of interrupts somebody asked for.
|
|
#define CONSOLE_CONTROL_INTERRUPT 0x02
|
|
|
|
// Set when there is a byte to be had. NOT set at the end of input, although a read would
|
|
// answer at once there: what it answers is 0xFF standing in for nothing, and calling that
|
|
// ready would make a loop that reads while READY spin on imaginary bytes forever. A loop
|
|
// like that now stops when the input does, which is what anybody writing one intends.
|
|
#define CONSOLE_STATUS_READY 0x01
|
|
// Set once input has run out for good. The data port still answers 0xFF, which is what it
|
|
// always did and what every program written before this expects, but 0xFF is also an
|
|
// ordinary byte and this bit is the only thing that can tell the difference.
|
|
#define CONSOLE_STATUS_ENDED 0x02
|
|
// Which mode the console is in, so that a program can put it back the way it found it
|
|
// rather than assuming it knows.
|
|
#define CONSOLE_STATUS_KEYMODE 0x04
|
|
// Whether the console is set to interrupt, for the same reason: everything a program can
|
|
// ask the console to be, it can also ask the console what it currently is.
|
|
#define CONSOLE_STATUS_INTERRUPT 0x08
|
|
|
|
// Puts the terminal back the way it was found. Registered with atexit and called from a
|
|
// handler for every signal that can end this process and be caught, because a machine that
|
|
// stops in key mode and does not undo it leaves the shell that started it unusable, which
|
|
// is a far worse failure than anything the program was doing. atexit alone is not enough:
|
|
// it does not run when a process is killed, and the ending that matters most is SIGHUP,
|
|
// which is what arrives when whatever launched this machine dies and takes the terminal
|
|
// with it.
|
|
void consoleRestore(void);
|
|
|
|
// One byte from the console, waiting if it has to. Everything that reads standard input
|
|
// goes through here: the emulator owns one byte of pushback, and stdio holding a buffer
|
|
// of its own behind that would make the status port lie about what is waiting.
|
|
uint8_t consoleReadByte(void);
|
|
|
|
// ---- Device classes ----
|
|
//
|
|
// What kind of thing is plugged into a port. Class 0 is not a device: reading an
|
|
// unimplemented port already gives zero, so "nothing there" needs no special case and a
|
|
// machine with no registry at all answers correctly by doing nothing.
|
|
//
|
|
// Classes 0x01 to 0x0F belong to the machine itself. Peripherals start at 0x10.
|
|
|
|
#define DEVICE_NONE 0x00
|
|
#define DEVICE_REGISTRY 0x01
|
|
#define DEVICE_CONSOLE 0x02
|
|
#define DEVICE_CONTROLLER 0x03
|
|
// The machine itself, which is what a reset is asking. In the range kept for the machine
|
|
// rather than among the peripherals, because it is not one: it is not attached to
|
|
// anything and cannot be unplugged.
|
|
#define DEVICE_MACHINE 0x04
|
|
#define DEVICE_TEST 0x10
|
|
#define DEVICE_REFUSE 0x11
|
|
#define DEVICE_MEMORY 0x12
|
|
#define DEVICE_DISK 0x13
|
|
#define DEVICE_VIDEO 0x14
|
|
|
|
// What a device brings besides itself. This means memory that somebody has to register
|
|
// with the controller, so the controller's own bank 2 does not count: it is already there.
|
|
#define DEVICE_FLAG_HAS_MEMORY 0x01
|
|
|
|
// ---- The disk ----
|
|
//
|
|
// Blocks are a page each, so a block number is the whole of a 16 bit address and the
|
|
// arithmetic never needs a multiply. Sixteen megabytes is absurd for this machine, which
|
|
// is the point: there is room for anything a filesystem might want to grow into later.
|
|
|
|
#define DISK_BLOCK_BYTES 256
|
|
|
|
// A fresh image is made the size of Program and Data together, which is a round number
|
|
// for this machine and small enough to read in a hex editor while it is being built.
|
|
#define DISK_DEFAULT_BLOCKS 512
|
|
|
|
#define DISK_COMMAND_READ 0x01
|
|
#define DISK_COMMAND_WRITE 0x02
|
|
|
|
// Set while an operation is still going, and it now really is set: a disk given a latency
|
|
// says busy, takes that many cycles, and finishes then. A program that does not wait gets
|
|
// whatever was in the buffer before, which is what the hardware would give it.
|
|
//
|
|
// It reads clear the whole time when the latency is zero, which is the default and how
|
|
// every test here has always run.
|
|
#define DISK_STATUS_BUSY 0x01
|
|
// Set when the disk cannot be written at all. Unlike the two bits above it, this is not
|
|
// about the last operation: it is a standing property of the medium, readable before
|
|
// anything is attempted. A write protected disk is barred here, in the device, rather
|
|
// than by anything in the filesystem, so writing blocks directly cannot get around it.
|
|
#define DISK_STATUS_PROTECTED 0x04
|
|
|
|
// Set when the last operation did not work: no image, or a block that is not on it.
|
|
// A disk that cannot read a block is an ordinary thing that happens to working programs,
|
|
// so it says so rather than stopping the machine.
|
|
#define DISK_STATUS_ERROR 0x02
|
|
|
|
// ---- Devices that take time ----
|
|
//
|
|
// A real device does not finish inside the instruction that asked it to. It says it is
|
|
// busy, takes as long as it takes, and is done when the machine has run that far - so the
|
|
// emulator needs somewhere to notice that time has passed. That is this: called once per
|
|
// instruction with the machine's clock, it lets any device whose moment has come finish.
|
|
//
|
|
// It is written for the disk and is not about the disk. Anything that will take time - a
|
|
// display that refreshes, a port that waits on the host - wants exactly this shape.
|
|
void deviceTick(unsigned long now);
|
|
|
|
// How many cycles a block read or write takes. Zero means the answer is there before the
|
|
// next instruction is, which is what this machine has always done and what every recorded
|
|
// test assumes.
|
|
void setDiskLatency(unsigned long cycles);
|
|
|
|
// Attaches an image, making one if it is not there. A disk is read only if the host will
|
|
// not let the file be written, or if writeProtect asks for it, which is the emulated
|
|
// equivalent of the tab on the side of a floppy. Returns 1 if it could not attach.
|
|
uint8_t attachDisk(const char *path, uint8_t writeProtect);
|
|
|
|
void detachDisk(void);
|
|
|
|
// How many bytes a device's entry in the registry runs to. Reading past the end gives
|
|
// zero, so the record can grow later without anything already written having to change.
|
|
#define DEVICE_RECORD_BYTES 2
|
|
|
|
uint8_t OutputHandler(uint8_t DataByte, uint8_t Address);
|
|
|
|
uint8_t InputHandler(uint8_t Address);
|
|
|
|
// ---- Interrupt lines ----
|
|
//
|
|
// One line per port. A device puts its line up to ask for attention, and the CPU takes
|
|
// it down when it answers. Which line a device uses is not a choice: a device on port N
|
|
// interrupts on N, which is what saves the machine from needing any arbitration.
|
|
//
|
|
// These belong to the bus rather than to the CPU. Nothing here is saved in a frame, and
|
|
// a program cannot read them except by being interrupted.
|
|
|
|
// Gives every device a moment to notice something the machine did not ask it about.
|
|
// Nothing here runs alongside the CPU: a device that waits on the outside world - the
|
|
// console is the only one so far - is never going to see a keystroke unless something
|
|
// asks it to look, and the CPU calling this between instructions is that something.
|
|
//
|
|
// It is called on every step and gets out of the way immediately when there is nothing to
|
|
// do, because there usually is not. A device that wants attention rarely is a device that
|
|
// must cost nothing when it does not.
|
|
void serviceDevices(void);
|
|
|
|
// Set when something has written MACHINE_RESET, and taken by the loop that acts on it. A
|
|
// request rather than an action, because a device cannot restart the machine from inside
|
|
// the instruction that asked - the CPU is mid-step and its state is not yet consistent.
|
|
int takeResetRequest(void);
|
|
|
|
void raiseInterrupt(uint8_t port);
|
|
|
|
void clearInterrupt(uint8_t port);
|
|
|
|
// The lowest numbered port with its line up, or -1 if none of them are.
|
|
int nextPendingInterrupt(void);
|
|
|
|
// ---- Refusing ----
|
|
//
|
|
// A device can refuse what it was asked to do. Interrupting is a device asking for
|
|
// attention later; refusing is a device saying no to the instruction happening now, so
|
|
// it has to stop the machine where it stands rather than raise a line and let execution
|
|
// carry on past the mistake.
|
|
//
|
|
// The refusal names a software vector, so the cause is known from the entry it arrives
|
|
// through, the same way every other fault on this machine works.
|
|
|
|
void refuseAccess(uint8_t faultVector);
|
|
|
|
// ---- Memory a device brings ----
|
|
//
|
|
// Returns the memory owned by the device on this port, or NULL if it owns none, and
|
|
// fills in how much of it there is. This is how the controller finds out what a device
|
|
// brings when it is told to register a bank.
|
|
//
|
|
// It is a direct look at the machine's device table rather than a conversation through
|
|
// the registry's port. The port protocol remembers which port it was asked about, so a
|
|
// controller that used it would silently lose the place of any enumeration a program had
|
|
// in progress. Same table, two consumers, and only one of them needs the protocol.
|
|
uint8_t *deviceMemory(uint8_t port, uint32_t *capacity);
|
|
|
|
// The vector a device refused with, or 0 if none did. Reading it clears it, because a
|
|
// refusal is answered once.
|
|
uint8_t takeRefusal(void);
|
|
|
|
// Which port did the refusing. Only meaningful alongside a refusal.
|
|
uint8_t refusingPort(void);
|
|
|
|
#endif // IO_H
|