videoReset zeroed video memory and then wrote the font and the sixteen colour schemes into it, and the comment above that said out loud what was wrong with it: "everything here is ordinary video memory". RAM does not wake up with anything in it. That was the last piece of magic in this device, and it looked harmless until something wanted the font BACK - a program that redefines a glyph had destroyed the only copy there was. So the device has a character generator, the way the machines this one is pretending to be really did, and the copy into RAM is a thing it DOES rather than a state it mysteriously starts in. Command port 0x39: bit 0 for the font, bit 1 for the schemes. THE RAM IS STILL RAM. A program may overwrite every glyph and every colour and should be able to, which is what makes this a tile engine rather than a text display. What changed is that it is no longer a one way door. NEITHER COMMAND CLEARS WHAT IT DOES NOT OWN. The font used to clear the whole of tile memory before writing itself, which was harmless while it happened only at reset and is wrong the moment a program can ask: a program that defined a tile of its own and then wanted its text back would have paid for it with the tile. The reason it is a chip rather than a file on the disk, which was the other candidate: the boot chain prints before CosmOS exists. Stage one prints "?" when there is nothing to boot, and if the font came off the disk then the message about the disk having failed would be the one thing that could not be drawn. A system that wants its own font still loads one over the top - the ROM is the floor, not the policy. Two things that had been worked around now simply work. The shell asks for both whenever a program exits, so a program that redefined a letter no longer leaves it unable to spell, and Grid no longer needs to have saved the screen to avoid handing back green text on blue. And the fault screen asks for the glyphs first, because a message spelled in somebody's tile graphics is no message at all. Five video checks. Two runs each for the font and the schemes, since the map holds a tile NUMBER and the glyph is looked up when the frame is drawn - so restoring changes every cell using it, including ones drawn before, and what the two runs differ by is the command. The third guards the decision not to clear: tile 200 has to survive the font coming back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
96 KiB
The SplitBit Programming Manual
This describes the machine: what the CPU has, what its instructions do, how something gets its attention, and what a SplitBit is made of. Everything here is true of any SplitBit, whatever happens to be running on it.
The Assembler Manual describes the language you write for it and the two file formats
it produces. Programs/CosmOS/README.md describes CosmOS, which is one operating system
that runs on this machine rather than part of the machine itself.
The Machine
SplitBit is a small 8 bit CPU. It is a Harvard Architecture machine with a separate 64k memory space for its Program and another for its Data.
It has ten registers:
-
The A and B Registers are each a general purpose 8 bit register.
- A and B are the operand registers for the ALU.
- A and B together form a 16-bit circular shift register, AB, in the context of the bit shift instructions, SHL and SHR.
- ALU operations do not overwrite A or B.
- A and B are preserved through subroutine calls. They can pass two bytes to a subroutine, but cannot directly pass bytes back from a subroutine.
-
The Q Register is the 8 bit ALU output register.
- All ALU operations store their result in Q.
- Q is not preserved through subroutine calls. It can be used to pass a one byte result back to the calling routine.
-
The Program Counter is a 16 bit pointer into the Program Memory.
- The PC points to the current operation the CPU is executing. It starts at whatever address the Boot Vector holds. See The Vector Table.
- The PC is only modified by the branch instructions, by CALL and RET, by SWI and RETI, and by an interrupt arriving. It cannot be directly set by the programmer.
-
The Data Pointers (0-3) are 16 bit pointers into the Data Memory.
- A DP points to a byte of data that the CPU can read or write, and each one initializes at Data Address 0x0000.
- A DP can be set arbitrarily by the programmer to any value.
- Every instruction that reads or writes Data Memory names the DP it works through. See Naming a Data Pointer.
- Data Pointers 0, 1 and 2 are preserved through subroutine calls. Data Pointer 3 is not.
- Because DP3 is not preserved, a subroutine can use it to pass an address back to the calling routine, in the same way Q passes back a byte. Unlike Q, an address can refer to as much data as you like.
-
The Stack Pointer is a 16 bit pointer into the Data Memory.
- The SP points to the next free slot, not to the last thing pushed. It initializes at location 0xFFFF, so the first push writes there and the byte pushed last always sits one above the SP.
- The SP is moved by the push and pop instructions, by CALL and RET, and by an interrupt arriving or returning. MVSD copies it out without moving it, and MVDS sets it outright. See The Stack Pointer, Set By Hand before using MVDS.
- The Stack lives in Data Memory, so a Data Pointer can be aimed at it and used to read what is on it. MVSD is how a program finds out where to aim.
-
The Status register is an 8 bit register whose various bits are used as flags. Only four of these flags are used in the current implementation.
- Bit 0 is the Carry/Borrow Flag. Any arithmetic operation either sets or clears it depending on whether or not the result causes Q to overflow/underflow. It is a 1 if a carry/underflow occurred, and a 0 otherwise. If A or B overflows or underflows from the use of an increment or decrement instruction, this flag will also be set. Non-overflowing increments or decrements will also reset it.
- Bit 1 is the Fault Flag. It is set when the CPU cannot get past something and no handler was installed to deal with it: a byte that is not an instruction, or a dispatch through an empty vector. See Faults.
- Bit 2 is the Interrupt Flag. It is set by SIF and cleared by CIF. While it is set the CPU answers devices asking for attention; while it is clear they wait. Arriving at a handler clears it, and RETI restores it along with the rest of the Status register. See Hardware Interrupts.
- RETI restores this register whole; SRET restores only the Interrupt Flag from it and leaves the rest as the handler left it, so a service can answer in the Carry Flag the same way a subroutine can.
- Bit 7 is the Halt Flag. It is set by the HALT instruction, and by a fault.
- There is no Wait Flag. The CPU stopped in a WAIT is stopped in a way no program can see, precisely because this register is saved and restored across an interrupt and a wait must not be. See WAIT under Special Operations.
Each condition has a branch both ways round, so a loop that carries on while something is not zero is one instruction rather than a branch over an unconditional one. Before these existed a quarter of every conditional branch in the corpus was written backwards and padded out, and each of those needed a label invented only to be jumped past.
A conditional branch reads the thing it names at the moment it runs. BRA looks at A, and it does not matter which instruction set A or how long ago. The two that read the Carry Flag are the exception, and they say so in their names. That is worth knowing when reading somebody else's code: nothing else on this machine leaves a hidden condition behind for a later branch to find.
Naming a Data Pointer:
Twenty one instructions work through a Data Pointer. Each of them carries a selector byte immediately after its opcode, naming which Data Pointer it means. LDD and STD move a pointer through a pointer, so they carry two selectors, the first naming the pointer being moved and the second naming the pointer that addresses it.
The selector is a full byte, but only enough of it is read to choose among the Data Pointers the machine has. A selector larger than the highest numbered pointer wraps around rather than being rejected, so it is the assembler's job to refuse to write one.
In assembly the selector is written on the mnemonic itself, as LDA.2 or LDD.1.0. Leaving it off means Data Pointer 0, so a program that only needs one pointer never has to mention them at all. See the Assembler Manual.
List of Instructions:
The Bytes column is the total length of the instruction, counting its opcode, any Data Pointer selectors, and any other operands it reads out of Program Memory.
Arithmetic and Logic Operations: 9 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| 10 | ADD | 1 | Adds A, B, and the Carry Flag, the result is stored in Q. |
| 11 | SUB | 1 | Subtracts B and the Carry Flag from A, the result is stored in Q. |
| 12 | AND | 1 | Bitwise and of A and B, the result is stored in Q. |
| 13 | OR | 1 | Bitwise or of A and B, the result is stored in Q. |
| 14 | XOR | 1 | Bitwise xor of A and B, the result is stored in Q. |
| 15 | NOTA | 1 | Bitwise inversion of A, the result is stored in Q. |
| 16 | NOTB | 1 | Bitwise inversion of B, the result is stored in Q. |
| 17 | SHL | 1 | A and B form a circular shift register. Rotate this register left. |
| 18 | SHR | 1 | A and B form a circular shift register. Rotate this register right. |
Register Operations: 13 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| 20 | RSTA | 1 | Resets A to 0. |
| 21 | RSTB | 1 | Resets B to 0. |
| 22 | INCA | 1 | Adds 1 to A. If it overflows, it sets the Carry Flag, otherwise, it resets it. |
| 23 | INCB | 1 | Adds 1 to B. If it overflows, it sets the Carry Flag, otherwise, it resets it. |
| 24 | DECA | 1 | Subtracts 1 from A. If it underflows, it sets the Carry Flag, otherwise, it resets it. |
| 25 | DECB | 1 | Subtracts 1 from B. If it underflows, it sets the Carry Flag, otherwise, it resets it. |
| 26 | INIA | 2 | Loads the next byte of Program Memory to A. |
| 27 | INIB | 2 | Loads the next byte of Program Memory to B. |
| 28 | CCF | 1 | Clears the Carry Flag. |
| 29 | MVQA | 1 | Copies Q into A. No flags are changed. |
| 2A | MVQB | 1 | Copies Q into B. No flags are changed. |
| 2B | SIF | 1 | Sets the Interrupt Flag. No other flags are changed. |
| 2C | CIF | 1 | Clears the Interrupt Flag. No other flags are changed. |
Q is where every ALU result lands, and Q is not itself an ALU operand, so MVQA and MVQB are how a result becomes the input to the next sum. Without them the only route is to store Q into Data Memory and load it back, which costs two instructions and needs a Data Pointer aimed somewhere useful. With them a running total can be kept in the registers and never touch memory at all.
Stack Operations: 7 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| 30 | PSHQ | 1 | Stores Q into Data Memory at the location referenced by the Stack Pointer then decrements the Stack Pointer. |
| 31 | PSHA | 1 | Stores A into Data Memory at the location referenced by the Stack Pointer then decrements the Stack Pointer. |
| 32 | PSHB | 1 | Stores B into Data Memory at the location referenced by the Stack Pointer then decrements the Stack Pointer. |
| 33 | PSHD | 2 | Stores the named Data Pointer to the stack, with the low byte on top. Decrements the Stack Pointer by two. |
| 34 | POPA | 1 | Reads the location referenced by the Stack Pointer from Data Memory into A then increments the Stack Pointer. |
| 35 | POPB | 1 | Reads the location referenced by the Stack Pointer from Data Memory into B then increments the Stack Pointer. |
| 36 | POPD | 2 | Restores the named Data Pointer from the stack, increments the Stack Pointer by two. |
Data Operations: 18 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| 40 | INCD | 2 | Increments the named Data Pointer. |
| 41 | DECD | 2 | Decrements the named Data Pointer. |
| 42 | LDA | 2 | Loads the byte addressed by the named Data Pointer into A. |
| 43 | LDB | 2 | Loads the byte addressed by the named Data Pointer into B. |
| 44 | STQ | 2 | Stores Q into the byte addressed by the named Data Pointer. |
| 45 | STA | 2 | Stores A into the byte addressed by the named Data Pointer. |
| 46 | STB | 2 | Stores B into the byte addressed by the named Data Pointer. |
| 47 | SETD | 4 | Loads the two bytes of Program Memory following the selector into the named Data Pointer, most significant byte first. |
| 48 | DPUP | 3 | Offsets the named Data Pointer up by the value of the byte following the selector. |
| 49 | DPDN | 3 | Offsets the named Data Pointer down by the value of the byte following the selector. |
| 4A | LDD | 3 | Loads the first named Data Pointer from the two bytes of Data Memory addressed by the second, most significant byte first. |
| 4B | STD | 3 | Stores the first named Data Pointer into the two bytes of Data Memory addressed by the second, most significant byte first. |
| 4C | MVSD | 2 | Copies the Stack Pointer into the named Data Pointer. The Stack Pointer itself is unchanged. |
| 4D | MVDS | 2 | Copies the named Data Pointer into the Stack Pointer, moving the Stack. Read The Stack Pointer, Set By Hand before using it. |
| 4E | DPUA | 2 | Offsets the named Data Pointer up by A. |
| 4F | DPDA | 2 | Offsets the named Data Pointer down by A. |
| 50 | DPUW | 2 | Offsets the named Data Pointer up by A and B together, A being the most significant. |
| 51 | DPDW | 2 | Offsets the named Data Pointer down by A and B together, A being the most significant. |
BRD is the only branch whose destination is not written into the program. Every other branch carries the address it goes to, fixed when the program was assembled; BRD takes it from a Data Pointer, which is what makes a table of addresses something a program can dispatch through rather than only read. Together with LDD it turns the Data Segment into somewhere a program can keep a list of places to go.
LDD and STD are how a program follows an address it has stored, rather than one the assembler wrote into the instruction. Together with more than one Data Pointer, they are what makes a table of addresses usable: one pointer walks the table while another follows whatever entry it is on. Naming the same pointer twice, as in LDD.0.0, makes that pointer follow the address it is currently holding.
Branch Operations: 10 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| 60 | BRI | 3 | Branch Immediately. Loads the immediate next two bytes of Program Memory into the Program Counter, first the most significant byte, then the least. |
| 61 | BRQ | 3 | Branch on Q. If Q is zero, loads the immediate next two bytes of Program Memory into the Program Counter. |
| 62 | BRA | 3 | Branch on A. If A is zero, loads the immediate next two bytes of Program Memory into the Program Counter. |
| 63 | BRB | 3 | Branch on B. If B is zero, loads the immediate next two bytes of Program Memory into the Program Counter. |
| 64 | BRC | 3 | Branch if Carry is set. |
| 65 | BRD | 2 | Branch to the address held in the named Data Pointer. |
| 66 | BNQ | 3 | Branch if Q is not zero. |
| 67 | BNA | 3 | Branch if A is not zero. |
| 68 | BNB | 3 | Branch if B is not zero. |
| 69 | BNC | 3 | Branch if the Carry Flag is clear. |
Subroutine Operations: 7 Instructions
A block of their own, because the branches and these outgrew one nibble between them. Each raw form sits immediately below the ordinary one it cannot be mixed with - RCAL under CALL, RRET under RET - since the frames differ and returning through the wrong one takes the machine somewhere nobody named.
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| 70 | RCAL | 3 | Raw call. Pushes only the Program Counter, then performs an immediate branch. Two bytes of Stack, and nothing is put back. Must be returned from with RRET. |
| 71 | CALL | 3 | Call subroutine. Pushes the Program Counter, Data Pointers 0 through 2, B and A to the Stack, then performs an immediate branch. This costs ten bytes of Stack. |
| 72 | SWI | 2 | Software Interrupt. The next byte names a software vector. Pushes an interrupt frame and dispatches through it. Never masked. |
| 73 | RETI | 1 | Return from an interrupt. Restores everything the frame holds and carries on from where the interrupt arrived. |
| 74 | RRET | 1 | Return from a raw call. Takes back the Program Counter and nothing else. |
| 76 | SRET | 1 | Return from a handler that has an answer. Restores what RET restores - A, B and Data Pointers 0 through 2 - and puts the Interrupt Flag back from the frame. The saved Q and Data Pointer 3 are dropped, so what the handler left in them is what the caller receives. |
| 75 | RET | 1 | Return from subroutine. Restores A, B, and Data Pointers 0 through 2 from the Stack, then sets the Program Counter to the instruction after the CALL. Data Pointer 3 and Q are left as the subroutine leaves them. |
Output Operations: 3 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| D0 | OUTQ | 2 | Writes the value of Q to an Output specified by the next byte of Program Memory. |
| D1 | OUTA | 2 | Writes the value of A to an Output specified by the next byte of Program Memory. |
| D2 | OUTB | 2 | Writes the value of B to an Output specified by the next byte of Program Memory. |
Input Operations: 2 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| E0 | INA | 2 | Writes the value of an Input to A. The input port is specified by the next byte of Program Memory. |
| E1 | INB | 2 | Writes the value of an Input to B. The input port is specified by the next byte of Program Memory. |
Special Operations: 3 Instructions
| Hex Code | Mnemonic | Bytes | Description |
|---|---|---|---|
| F0 | NOP | 1 | Perform no Operation, increment the Program Counter. |
| FE | WAIT | 1 | Stops fetching until a device asks for attention. |
| FF | HALT | 1 | Stops CPU Execution. |
WAIT is not a gentler HALT and the two are not interchangeable. HALT stops the machine and is how a program says it has finished; WAIT stops only the CPU's use of the bus. The clock runs, devices run, and the moment any of them raises a line the CPU carries on with the instruction after the WAIT.
A line that is already up means there is nothing to wait for, and WAIT does nothing at all. That is what makes the ordinary shape of it safe:
waitForDisk:
INA 0x23 ; The status port.
INIB 0x01
AND
BRQ ready ; Not busy, so there is nothing to wait for.
WAIT
BRI waitForDisk
ready:
Notice that on a device quick enough to finish before the first look, the WAIT in that loop
never runs at all. That is fine, and it is why the next section exists.
Test the device, then wait. If the device finishes in the gap between the two, its line is standing when WAIT runs and the wait is skipped rather than slept through.
Any line ends the wait, whether or not the Interrupt Flag is set. Masking decides who answers a request, not whether it happened - so a program can sleep on a device it has no handler for at all and simply read the device's status afterwards, which is what the loop above does. If the flag is set, everything happens as it always does: the handler runs and RETI comes back to the instruction after the WAIT.
A line that wakes the CPU without being dispatched to a handler is taken down by the WAIT itself. Otherwise it would still be standing at the next WAIT, which would return at once, and at the one after that - the program would spin exactly as it would have without the instruction, while looking as though it slept.
Waiting is not a bit in the Status register, and that is deliberate rather than an oversight. Status is pushed into the interrupt frame and restored by RETI, so a machine that took an interrupt while waiting would come back from the handler still waiting, and wait again for the thing it had already been given.
Nothing wakes a WAIT that no device will ever interrupt. That is a program saying it has nothing to do until something happens, and if nothing can happen it waits for ever, the same way an unconditional branch to itself loops for ever.
Making It Do Something
Making It Print Something:
Port 0 is the console: writing sends a byte to standard output and reading takes one from standard input. It answers on two more ports than that, which are described under The Console and which a program can ignore entirely if all it wants is to read and write bytes. Everything else this machine has is listed under Devices, and a program that wants to know what is actually there asks the bus registry rather than assuming.
Example Program: Hello World
; This is a basic hello world program for the SplitBit CPU.
; We'll create a loop that outputs each byte of our string to Output 0, the text console.
#Program
Start:
LDA ; Load a byte of the string into A.
BRA End ; If A is zero, branch out of the loop.
OUTA 0x00 ; Output the value in A to Port 0, the text console.
INCD ; Increment the Data Pointer to the next byte of the string.
BRI Start ; Branch immediately to the start of the loop.
End:
INIA 0x0A ; We'll load a linefeed into A and output it to make it look nice.
OUTA 0x00 ; Output it to the text console.
HALT ; Terminate the program.
#Data
"Hello, World!"
Nothing in that program names a Data Pointer, so all of it runs through Data Pointer 0.
The Console:
Port 0x00 is the oldest thing on this machine and it has not changed: writing sends a byte out, reading takes one in and waits until there is one.
On a machine with a screen, the console draws. It is a display controller as well as a port: it owns a font, keeps a cursor, and scrolls - which is what a video terminal's character generator did, and is why a program written before there was a screen puts text on one without being changed. See Writing On The Screen below. Every program ever written for SplitBit uses it that way and still does. What is new is that a program can say what it wants a keypress to mean, can ask whether a read would have to wait, and can arrange to be told when a byte arrives instead of having to ask at all.
| Port | Register |
|---|---|
| 0x00 | Data. Writing sends a byte out, reading takes one in and waits for it. |
| 0x01 | Status. Bit 0 a byte is waiting, bit 1 input has ended, bit 2 the console is in key mode, bit 3 the console is set to interrupt, bit 4 a cursor is being shown. |
| 0x02 | Control. Bit 0 asks for key mode, bit 1 asks the console to interrupt when a byte arrives, bit 2 asks for a cursor. Writing 0x00 asks for none of them, which is how the console starts. |
The control port's two bits are independent, and one write sets both. Everything the control port can ask for, the status port reports, so a program can put the console back the way it found it instead of assuming it knows.
Two Kinds Of Input:
In line mode, which is how the machine starts, the terminal holds what is typed until Return and does the echoing and the backspacing on the way. A program reading the data port gets a finished line, one byte at a time. This is what the machine has always done and what a shell wants.
In key mode the terminal stops holding the line. Keys arrive as they are pressed, and nothing echoes them, so a program that wants them seen has to send them back out itself. This is also the only mode in which the keys that are not characters arrive at all - see below. The editing goes with the echo: there is no backspace, because backspace was the terminal's doing and the terminal is no longer involved. That is not a choice this machine makes, it is what asking for keys means, and a program that wants keys is expected to want it.
A program is expected to put the console back in line mode before it finishes. CosmOS also does it whenever a program returns, because a program that stops early would otherwise hand back a shell with no echo, and a shell has no way to find out that happened.
Keys That Are Not Characters:
An arrow key is not a letter, and for a long time there was no byte for one, so it did not reach this machine at all: a window threw it away for want of anywhere to put it, and a terminal sent an escape sequence which arrived in the middle of whatever was being read and made it unrecognisable.
The console names them now. Each arrives as one byte, above ASCII so that nothing written before them can collide:
| Byte | Key |
|---|---|
| 0x80 | Up |
| 0x81 | Down |
| 0x82 | Left |
| 0x83 | Right |
| 0x84 | Home |
| 0x85 | End |
| 0x86 | Delete, meaning the character under the cursor |
Backspace is 0x08 and always has been. It is a different key from Delete and does a different thing, which is why they are two values and not one.
0x80 to 0x8F belong to the console, so a program can tell a key from a character by testing that range. The values above 0x86 are not used yet.
The console normalises, which is what it has always done. Behind a window it turns the key somebody pressed into a byte; on a terminal it turns ESC [ A and its neighbours into the same byte. That is the same act it performs on Return and Backspace, and it is why a program does not have to know which of the two it is talking to. The translation happens only when there really is a terminal: a file or a pipe holds exactly the bytes somebody put in it, and a program reading one gets those bytes untouched - which is also how a test presses an arrow key.
These arrive in key mode only. Line mode delivers characters, and a program in line mode is being handed a line that something else has already finished editing, so a key meaning "move the cursor left" arrived too late to mean anything. The console drops them there. This is what a terminal does too: it has always given a program in line mode backspace and line kill, and has never given it arrow keys.
But asking the status port in line mode does not throw one away. A key line mode will not deliver is not the same as a key that is gone, because the mode can change: a program that looks at the status port and then asks for key mode - which is exactly what a system does before it reads a line - would otherwise find that the first key it was reaching for had been swallowed by the looking. So the console holds it and delivers it as soon as something is willing to take it. Reading the data port in line mode does discard it, and must: that read is the delivery, and a key held there would be met again forever.
What a key means is not the console's business. Where the cursor goes, what the line looks like afterwards and what was typed before are all decisions, and decisions belong to whatever is reading - which on this machine is usually CosmOS, whose shell edits its own line. The console says which key was pressed and stops there, exactly as the disk says what a drive is and says nothing about what should be on it.
Reading Without Waiting:
Reading the data port waits in both modes. The status port is how a program declines to wait, and keeping that in one place is deliberate: a read that sometimes blocked and sometimes did not, depending on a mode set somewhere else, would be a program that works until it does not.
So a simulation that should stop when somebody presses a key asks the status port between steps, and only reads the data port once it knows there is something to read:
INA 0x01
INIB 0x01 ; Bit 0: is a byte waiting?
AND
BRQ nobodyPressedAnything
INA 0x00 ; There is one, so this will not wait.
The End Of Input:
Reading the data port when input has run out gives 0xFF, which is what it has always given and what programs written before any of this expect. But 0xFF is also an ordinary byte, and nothing could tell the two apart. Bit 1 of the status port is what tells them apart now.
Bit 0 is not set once input has ended, even though a read would answer immediately. The bit means a byte is there to be had, and at the end of input there is not. That way a loop that reads while bit 0 is set stops when the input does, instead of taking imaginary bytes forever.
Being Told Instead Of Asking:
Bit 1 of the control port asks the console to put its interrupt line up when a byte arrives, so a program can get on with something else and be told. The console is on port 0x00, so that is the vector a key comes through, named the way every device is:
#Vectors
Device 0x00 keyHandler
The handler is entered because the console had something to say, and asks the status port what. There are two possible answers, and the second is why a program can rely on this instead of also polling:
keyHandler:
INA 0x01
INIB 0x02 ; Bit 1: has input ended?
AND
BNQ noMoreKeys ; Nothing more is ever coming.
INA 0x00 ; The byte this interrupt was about.
OUTA 0x00 ; Nothing echoes in key mode, so send it back out.
RETI
The end of input raises the line once, as well as an arriving byte. A program driven entirely by interrupts would otherwise sit forever waiting to be told about a key that cannot arrive.
The line goes up at most once per byte. The console holds one byte, so while that byte is still there, nothing new can arrive to ask about, and a handler that returns without reading it is simply not called again. This is what a receive register holding one byte does, and it means a handler cannot interrupt-storm the machine by forgetting something. The cost is the other half of the same fact: bytes arriving while that one is unread are lost, exactly as they would be on hardware.
The two control bits do not depend on each other, so a program may ask to be interrupted in line mode. The terminal still holds what is typed until Return, and then the whole line arrives at once as a run of interrupts, one per byte. That is rarely what anyone wants, but a control bit that quietly did nothing because of another control bit would be worse.
A program that interrupts on input must not also block on the data port. Reading it waits, and nothing else in the machine runs while it is waiting, so a program that does both has chosen to wait after asking not to. Interrupting and blocking are two answers to the same question, and a program wants one of them.
The machine notices an arriving key between instructions, and does not look on every single one. The delay is about a quarter of a millisecond, which is shorter than the gap between two keystrokes by a wide margin and shorter than anything a person can perceive at all.
The Stack Pointer, Set By Hand:
MVDS copies a Data Pointer into the Stack Pointer. It is the most dangerous instruction on this machine, and it is here for one job.
Everything a program is in the middle of doing lives on the Stack. Moving the Stack Pointer does not move any of it, and does not destroy any of it either: it steps away from it. Every return address, every saved register and every interrupt frame stays exactly where it was in Data Memory, and the Stack Pointer is simply no longer looking at it. A RET taken while the Stack Pointer is somewhere else does not go back to whoever called: it reads two bytes from wherever the Stack Pointer now points and branches there. If those bytes are a string, execution lands in the middle of it.
That is not a bug to be worked around. It is what moving the Stack means, and it is why nothing else on this machine can do it.
The job it is here for is reclaiming the Stack from a program that has stopped running. A system that loads other programs and takes the machine back afterwards has a problem without it. A program that gives up part way through leaves everything it pushed behind, and the interrupt frame carrying its request to stop is on there too. Nothing unwinds any of that, because the program is not going to return. Without MVDS the Stack only ever moves downward, a little more with every program run, and a shell cannot outlive many of them.
The pattern is to write the Stack Pointer down before giving the machine away and put it back afterwards:
; Before handing control to a program, remember where the Stack was.
MVSD.0
SETD.1 SavedStack
STD.0.1
; ... the program runs, and eventually asks to stop ...
; Taking the machine back. The Stack is ours again, and everything the
; program left on it is gone.
SETD.1 SavedStack
LDD.0.1
MVDS.0
Coming Back From It:
A routine that moves the Stack and then puts the Stack Pointer back exactly where it found it can return in the ordinary way. The return address and the frame were never destroyed, only stepped away from, and RET or RETI finds them precisely as they were. Nothing special is needed for this to work, but one thing is required for it to keep working: nothing done while the Stack was elsewhere may reach back over those bytes. A borrowed Stack has to be somewhere the old one is not, and it has to be far enough away that pushing on it cannot walk into the old one.
A routine that moves the Stack and leaves it moved is the other case, and that one cannot return at all, because its return address is on the Stack it walked away from. This is not a limitation to work around either. It is the entire point when the reason for moving is that whoever owned that Stack is not coming back. A handler for a service meaning "give the machine back" restores the system's Stack and then branches to the prompt, because there is nowhere left to return to.
Where To Keep The Old One:
This is the awkward part. A fixed location in Data Memory is the obvious place to write the old Stack Pointer down, and it works until two routines that do this are nested. The inner one overwrites the outer one's copy, the outer one restores the inner one's Stack, and it never gets home. Nothing about that failure points back at the cause.
The answer that nests is to keep the old Stack Pointer on the borrowed Stack itself. Move first, then push it, and pop it back before returning. Each nesting keeps its own copy, on its own Stack, and no two of them can collide:
MVSD.3 ; Where the Stack is now.
SETD.0 BorrowedTop ; The highest address of somewhere a Stack can live,
MVDS.0 ; because the Stack grows downward from here.
PSHD.3 ; The old Stack Pointer, kept on the borrowed Stack.
; ... work, with the borrowed Stack under us ...
POPD.3
MVDS.3 ; And put it back.
RET ; Which now finds the frame exactly as it was left.
One last thing. An interrupt arriving while the Stack Pointer is somewhere unusual builds its frame there, so anywhere it is set has to be somewhere a Stack can actually live. And a system holding a saved Stack Pointer for a program it is running has to keep it somewhere that program cannot reach, or a program that scribbles over it takes the machine down with it on the way out.
When Something Else Wants Attention
Answering A Line:
A device raises its line when it has something to say, and something has to take it down again. There are three things that do, and between them they cover every way a program can find out that a device is finished.
Being interrupted takes it down. The dispatch does it, before the handler runs, which is why a handler does not have to and why a handler that forgets does not spin.
Being woken from WAIT with the Interrupt Flag down takes it down, because nobody else is
going to. A masked program has nowhere to dispatch to, and a line left standing would be found
by the next WAIT, and the one after that, and the program would spin exactly as it did
before while appearing to sleep.
And reading the port that answers the device takes it down. For the console that is the data port, because taking the byte is what answers the console. For the disk and the screen it is the status port: the operation finished, and whether it worked is what Status is for.
That third one is the one to have in mind, because without it the loop above has a hole in it.
A program that polls, finds the device already done and never reaches its WAIT has used none
of the first two. The line stands - and it stands for the rest of the machine's life, because
nothing is ever going to come along and answer it.
What that costs is not paid by the program that leaves it. That program never set the
Interrupt Flag; it was masked throughout. The bill arrives later, at whoever does. The boot
chain reads the disk to load a program, leaves the line up, and hands over - and the loaded
program is interrupted on behalf of a read that finished before it existed, through a vector
table that has no entry for a device it never touched. It faults on the instruction after its
SIF. Programs/Examples/tune.asm is how this was found: run through Once, it set up its
whole sound and then died four bytes before playing a note.
So: a status read is an acknowledgement, and a program that wants to be interrupted by a device should not poll it.
Interrupts:
An interrupt is an involuntary transfer of control. A subroutine call is agreed to by the code that makes it, so CALL can leave Q and Data Pointer 3 alone and let a subroutine pass results back through them. An interrupt arrives in code that has never heard of it, where Q and DP3 are ordinary working registers, so it saves everything:
| Pushed | Bytes |
|---|---|
| The address to resume at | 2 |
| Data Pointers 0 through 3 | 8 |
| B, then A, then Q, then Status | 4 |
That is fourteen bytes of Stack per interrupt, and the order matches CALL: least significant byte first, lowest numbered Data Pointer first.
Entry clears the Interrupt Flag, so a handler runs without being interrupted again unless it sets the flag itself. The old value of the flag rides into the frame inside the Status register, so RETI restores it along with everything else and nothing has to remember it separately.
RETI pops the frame and carries on from the address in it. The frame holds a real address rather than an adjusted one, so a handler can read it and make sense of where it came from.
A handler reaches its own frame with MVSD. The Stack Pointer points at the next free slot, so everything in the frame sits above it:
| Offset from the Stack Pointer | Holds |
|---|---|
| 1 | Status |
| 2 | Q |
| 3 | A |
| 4 | B |
| 5 and 6 | Data Pointer 3, high byte then low |
| 7 and 8 | Data Pointer 2, high byte then low |
| 9 and 10 | Data Pointer 1, high byte then low |
| 11 and 12 | Data Pointer 0, high byte then low |
| 13 and 14 | The address to resume at, high byte then low |
Writing to those bytes changes what RETI restores. Adding one to the address at offsets 13 and 14 is how a fault handler steps over the byte that failed and carries on, and rewriting the saved registers is how a handler hands something back to the code it interrupted.
SWI is never masked, because it is an instruction the program deliberately ran rather than something a device asked for.
The Vector Table:
The top kilobyte of Program Memory is reserved for vectors. Each entry is two bytes, most significant byte first, and holds a Program Memory address.
| Address | Contents |
|---|---|
| 0xFC00 | Software vectors 0 to 255 |
| 0xFE00 | Hardware vectors 0 to 255, one for each I/O port |
The Program Segment may not run past 0xFBFF. The assembler refuses to assemble a program that would.
The software vectors are given out like this:
| Vector | Meaning |
|---|---|
| 0 | The Boot Vector. Where the machine begins at power on. |
| 1 | The Soft Reset Vector. A warm restart. |
| 2 | A byte that is not an instruction. |
| 3 | A device refused a write, because it landed inside a raised fence. GuardViolation. |
| 4 | A bank was named that has nothing in it, or an access ran past its end. BankFault. |
| 5 to 15 | Held back for faults not yet defined. |
| 16 to 63 | Pinned. Numbers that separately assembled programs have to agree about, written down rather than allocated. |
| 64 and up | A program's own, given out by the assembler in the order they are named. |
A programmer almost never writes a vector number. Handlers are named in the Vector Segment of an assembly file and used by name, the same way every other address in SplitBit is worked out by the assembler rather than typed.
The exception is the range from 16 to 63, and it exists because the assembler only ever sees one program. A program calling osExit and the system implementing it are assembled separately, so nothing the assembler can look at ties the two together; the only thing that can is a number both sides write down. Those numbers come from that range, and the assembler never allocates one there, so a number agreed between two programs can never be handed to a third by accident. See the SplitBit Assembler Manual.
The table holds two kinds of entry, and they behave differently when they are zero.
Software vectors 0 and 1 are start addresses rather than handlers. Vector 0 is the Boot Vector: the CPU reads it at power on and begins executing there. Vector 1 is the Soft Reset Vector, for a warm restart. Nothing dispatches through either of them, and 0x0000 is an ordinary address to begin at, so a zero in one of these two means exactly what it says: start at 0x0000.
That is deliberate, and it is what lets a program that carries no vector table of its own still run. Program Memory reads as zero where nothing was loaded into it, so such a program's Boot Vector reads 0x0000, which is where its first instruction sits.
The cost of that rule is worth knowing: a machine with neither a Boot Vector nor anything at 0x0000 will start executing zeroes, and 0x00 decodes as ADD, so it will wander instead of stopping. There is no way for the CPU to tell that case apart from a program that genuinely begins at 0x0000.
Every other entry is a handler. A zero in one of those means no handler is installed, and dispatching through it is a fault rather than a jump to the bottom of memory.
The exemption for vectors 0 and 1 belongs to that one read the CPU makes at reset, not to the entries themselves. Anything that dispatches treats a zero as no handler, whichever entry it is, so SWI SoftReset through an empty Soft Reset Vector faults like any other. That is what makes SWI SoftReset the way to ask for a warm restart once one has been installed.
Hardware Interrupts:
A device asks for attention by putting its line up. Which line it uses is not a choice: a device on port N interrupts on N, and arrives through hardware vector N. That is what spares the machine any arbitration, and it means a program can work out what a device will do by knowing where it is plugged in.
A line is answered between instructions and never inside one, so the address in the frame is always the start of an instruction.
The Interrupt Flag decides whether lines are answered at all. While it is clear, a line that goes up stays up: masking holds a device off, it does not lose what the device was asking for. The moment the flag is set, the line is answered on the very next step. Since entry clears the flag again, a handler is not interrupted while it works unless it sets the flag itself.
When several lines are up at once, the lowest numbered port is answered first. This is a scan rather than a priority scheme, so there is nothing to configure and nothing to explain: a programmer works out what happens next by reading the port numbers.
Answering a line takes it down, so a device that wants attention again has to ask again. A handler returning with RETI restores the Status register, and with it the Interrupt Flag as it was before, so anything still waiting is answered next.
If a device interrupts and its vector is empty, that is a fault: the machine stops and the emulator says which port asked and where it was.
Faults:
If the CPU reads a byte from Program Memory that does not decode to an instruction, it dispatches through Software Vector 2.
Faults get a vector each rather than sharing one, so that a handler always knows what happened from the entry it arrived through. That is why the machine has no fault cause register to read.
| Vector | What happened |
|---|---|
| 2 | A byte in Program Memory does not decode to an instruction. |
| 3 | A device refused a write that landed inside a raised fence. |
| 4 | A bank was named that has nothing in it, or an access ran past its end. |
| 5 | A software vector was dispatched through and had no handler. |
| 6 | A device interrupted and its hardware vector had no handler. |
Vectors 7 through 15 are held back for the causes that come later.
The address in the frame is the address of the offending byte itself, not the one after it. A handler can therefore read the byte that failed and say what it was. It also means a handler that returns with a bare RETI will meet the same byte again, because resuming past a fault means deciding where to resume, and only the handler knows that.
If nothing is installed at Vector 2, the CPU sets the Fault Flag and the Halt Flag and stops, leaving the Program Counter on the offending byte. The emulator then reports the byte and its address, and exits with a non zero status.
Stopping matters because the alternative is worse. A byte that means nothing is almost always a sign that execution has wandered into data, or that a program was built for a machine with instructions this one does not have. Stepping over it and carrying on turns a clear failure into a program that appears to run and quietly does the wrong thing.
Nowhere To Go:
Vectors 5 and 6 are the fault of dispatching through an empty entry, and they were a long time coming, because the thing that would hand that fault over is the thing which has just found nothing to hand it to. Until they existed, a SWI naming a service the system does not implement stopped the machine and no program could do anything about it - and calling a service that is not there is an ordinary mistake.
Which entry was empty arrives in Q, and it is the only thing on this machine a handler is given in a register. That is not a fault cause register by another route: the vector still says what happened, and Q says which of the 256 entries it happened about, which is a parameter and not a cause. It costs nothing, because the frame already saved the Q the interrupted program had and RETI puts it back.
The two are separate entries because they are separate mistakes with separate fixes. A missing software vector is usually a program calling something that is not there; a device with nobody listening is usually a program that asked to be interrupted and forgot the handler.
The frame's address is past the SWI, unlike every other fault here, because the instruction did dispatch - it was the entry that was empty. A bare RETI therefore carries straight on, where a bad opcode or a refused port would meet the same instruction again.
If Vector 5 or 6 is itself empty, the machine stops the way it always did. It has run out of places to go, and looping there would be worse than stopping. A handler that commits the same fault it was called about recurses like any other, which is the same bargain as a Vector 2 handler containing a byte that does not decode.
Refusing:
A device can refuse what it was asked to do. This is not the same as interrupting. An interrupt is a device asking for attention later, answered between instructions once the CPU is ready. A refusal is a device saying no to the instruction happening now, so the machine stops where it stands rather than carrying on as though the access had worked.
A refusal names a software vector, so a handler knows what happened from the entry it arrived through, the same as every other fault. The frame carries the address of the instruction that was refused, so a handler can see which one it was.
That means a handler returning with a bare RETI will meet the same refused instruction again. A handler that means to carry on past it steps the saved address on by two, since the input and output instructions are an opcode and a port.
If nothing is installed for the vector a device refused with, the machine stops and the emulator says which port refused and where.
What A Machine Is Made Of
Devices:
| Port | Device | Class |
|---|---|---|
| 0x00 - 0x05 | The console. See The Console. Writing to 0x00 sends a byte to standard output, reading takes one from standard input. It interrupts on 0x00, its base port, when asked to. | 0x02 |
| 0x10 | A test device. Writing anything to it puts its own line up, so that interrupt handling can be exercised without waiting on anything. The byte written is ignored. | 0x10 |
| 0x11 | A device that refuses everything, in both directions, so that refusal can be exercised without the memory controller. | 0x11 |
| 0x20 - 0x28 | The disk. See Storage. It interrupts on 0x20, its base port. | 0x13 |
| 0x13 | The machine itself. Writing 1 asks it 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 - and a program that owns the whole machine has no system to ask. The disk is not unplugged and keeps what was written to it; the vector table is cleared, because a handler left behind would aim an interrupt into a program that is no longer running. | 0x04 |
| 0x12 | A device that owns 256 bytes of memory. Writing to its port fills that memory with the byte written, standing in for a disk controller reading a sector. Its memory is unreachable until it is registered as a bank. | 0x12 |
| 0x30 - 0x3F | The screen. See The Screen. It brings video memory, which is unreachable until it is registered as a bank. | 0x14 |
| 0x40 - 0x4F | The sound device. See Making A Noise. Four channels, played by writing to ports; it brings no memory. | 0x15 |
| 0x50 - 0x54 | The timer. See Keeping Time. Counts the machine's cycles and says when a period has gone by. | 0x16 |
| 0xE0 - 0xEF | The memory controller. See The Memory Controller. | 0x03 |
| 0xFF | The bus registry. See Asking What Is There. | 0x01 |
The Screen:
A tile engine, on ports 0x30 to 0x3F. The CPU writes cell indices and the device turns them into pixels.
That indirection is the whole reason a screen is affordable here. At a megahertz a frame is 16,667 cycles, and pushing a full 320 by 200 picture a byte at a time is 64,000 bytes - four frames of work for one frame of screen. A 40 by 25 map is 2,000 bytes, and a program that changes two cells writes four. The cost of a screen becomes the number of cells that changed rather than the number of pixels on it.
It follows that colour depth is free. The map is the same size whatever is behind it, so the tiles are eight bits deep: an 8 by 8 cell is 64 pixels and each one picks independently out of 256 colours. There is no limit of two to a cell, or four, or sixteen.
Video Memory:
One bank, brought by the device and reached only through the memory controller, like the disk's buffer. It keeps what is in it between frames, so a program writes the part that changed and the rest stays as it was.
| Address | Holds |
|---|---|
| 0x0000 - 0x3FFF | Tile memory. 256 tiles of 8 by 8, one byte a pixel, so tile n begins at n times 64. |
| 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. |
| 0x0000 - 0xF9FF | In bitmap mode, the picture instead: 64,000 bytes, one to a pixel. |
| 0xFC00 - 0xFFFF | The palette. 256 entries of four bytes: red, green, blue, and one unused. |
The bitmap is the same memory as the tiles and the map, which is what shared video memory has always been, and there is nowhere else it could be: 64,000 bytes of picture in a 65,536 byte bank leaves room for nothing beside it. Going to bitmap mode does not clear the text screen - it stops calling it one, and coming back finds the tiles and the map holding whatever the picture put there.
The palette is at the top, out of the way of both, because it is the one thing that means the same in every mode.
A map row is a page whether the mode fills it or not, and that is arithmetic rather than waste. This machine has no multiply, so on a 40 column screen every cursor move would otherwise cost a row times 40 in software - a tax on the most common operation in the system. At a page a row there is no arithmetic at all: the row number is the high byte of the address and the doubled column is the low byte.
A palette entry is four bytes for the same reason. Entry n begins at n times four, which is a shift; three bytes would need a multiply.
Cells:
Two bytes. The first says which tile, the second how to colour it.
The low nibble of the second byte is added to every palette index in the tile, sixteen at a time. A tile drawn in indices 0 to 15 therefore appears in any of sixteen colour schemes without a second copy of it in tile memory. A tile that wants all 256 colours leaves the nibble at zero and gets them. The addition wraps, because a byte plus a byte is a byte.
The high nibble is reserved and should be left at zero, so that a meaning can be given to it later without changing what already-written programs mean.
Registers:
| Port | Register |
|---|---|
| 0x30 | Status. Bit 0 a frame has gone by, bit 1 the screen is set to interrupt. |
| 0x31 | Mode. |
| 0x32 | Columns, read only. |
| 0x33 | Rows, read only. |
| 0x34 | Scroll row. Which of the map's 128 rows is drawn at the top. |
| 0x35 | Control. Bit 0 asks to be interrupted at each frame. |
| 0x36 | Scroll column. Which of the map's 128 columns is drawn at the left. |
| 0x37 | Fine X. How many pixels into that column the screen begins, 0 to 7. |
| 0x38 | Fine Y. How many pixels into that row the screen begins, 0 to 7. |
| Mode | Screen | Cells |
|---|---|---|
| 0 | 320 by 200 | 40 by 25 |
| 1 | 640 by 400 | 80 by 50 |
| 2 | 320 by 200 | none: a byte a pixel |
The first two are 8 by 8 cells over the same engine, and the pixel count costs a program nothing, because it only ever writes the map.
Mode 2 is the other kind of screen, where a byte is a palette index and there is no tile to look it up in and no attribute to add. What it costs is the other way round: a whole picture is 64,000 bytes, four frames of work at a megahertz, so it is the mode to draw in and leave alone or to change a corner of, not the mode to animate all of. Programs/Examples/picture.asm fills one in 127 bytes of program.
A bitmap has no columns and no rows, and asking says so: both registers read zero, which is the true answer rather than a leftover from the last mode. The console asks, and a console told there is no character screen has nowhere to put a glyph and draws nothing - it still says everything down the serial line. The alternative is what a machine with shared video memory really does, which is scribble marks nobody can read across somebody's picture. A mode that does not exist is not taken, and is not a fault either: a screen is a poor place to stop the machine, and a program that asked for something impossible still has the screen it had.
How big the screen is, is asked for rather than assumed. A program written once can find out what it is running on.
The Frame:
A screen finishes drawing sixty times a second and then has a moment before it starts again. That moment is the one safe time to change what it is drawing - and it is also the only regular beat this machine has. There is no clock here. Every program that wanted to happen at a certain speed has until now counted instructions and hoped, which is why Snake's pause quietly halved the day a cycle stopped being an instruction and became a memory access.
Sixty a second, counted in the machine's own cycles rather than the host's. So a program sees the same number of frames in the same number of cycles however fast anything really ran, which is what makes a frame something a test can count and a recorded result can contain.
Status bit 0 goes up when a frame has gone by, and reading the status port puts it down. Looking is what answers it: a frame that has been noticed is not still waiting to be noticed, and a program polling in a loop would otherwise see the first frame for ever.
Control bit 0 asks to be interrupted instead, on hardware vector 0x30, which is the screen's base port. It is off when the machine starts, and that is not caution for its own sake: an interrupt with nothing installed to catch it is a fault, so a screen that began interrupting the moment it was switched on would take down every program written before frames existed. Asking to stop takes down any request already standing, for the same reason the console's interrupt bit does.
More than one frame can go by between two looks - the machine runs in batches, and a slow host covers several at once. The flag and the line are each one thing, so several frames still mean one of each. A missed frame is missed, which is what missing one means.
This is what WAIT was built for. A program does its work, waits, and is woken:
INIA 0x01
OUTA 0x35 ; Interrupt me at each frame
SIF
loop:
; ... draw ...
WAIT ; Nothing to do until the screen says so
BRI loop
A machine doing that is asleep between frames rather than spinning, and the difference is visible: the cycles it spent are counted as idle rather than as bus, so a program that waited properly and one that polled in a loop can be told apart even though they print the same thing and take the same time.
Scrolling:
The map is a ring, and the Scroll register says which of its 128 rows is drawn at the top. Screen row r shows map row scroll + r, wrapped.
Scrolling therefore moves a register and no memory at all. That is not a small saving. Moving a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is 1,920 cycles even with the controller widened - twelve percent of a frame, for one line. A program printing a single page would spend six frames shuffling memory. Here it is one write to a port.
And the rows that scrolled off are still in the map, which is where a terminal on this machine gets scrollback without having to keep any.
The columns are the same ring the other way. A map row is 256 bytes and a cell is two, so there are 128 of them whatever the mode shows - 88 more than a 40 column screen displays, and 48 more than an 80. Scroll column says which one is at the left, and screen column c shows map column scroll column + c, wrapped. A map wider than the screen costs nothing to have, because the map is that wide already.
Scrolling By Less Than A Cell:
The two registers above move the view a whole cell at a time, which is a scrolling text screen rather than a scrolling picture: eight pixels is a long way to jump sixty times a second. Fine X and Fine Y are the remainder - how far into the cell at the origin the screen actually starts. Together the four registers place the view anywhere in the map to the pixel.
The screen no longer begins on a cell boundary when a fine register is not zero, so the cells at two edges are partly off it. That is the device's problem and not a program's: it draws one more row and one more column than fit and clips them.
Fine does not carry into coarse. Writing 8 to a fine register is writing 0, because only the low three bits of it mean anything - it is not one cell along. A program scrolling past a cell edge advances the coarse register itself:
; One pixel to the left, carrying when it runs out of cell.
SETD.0 FineX
LDA.0
INCA
INIB 0x07
AND
STQ.0
BNQ scrolled ; Still inside the cell.
SETD.0 CoarseX
LDA.0
INCA
STA.0
OUTA 0x36
scrolled:
The alternative was to let a write of 8 step the column and set the fine part to zero, and it was rejected for one reason: a program that scrolls has to know where it has got to, and if the hardware carries then the only way to find out is to read the register back. Keeping them apart means the program already knows, because it did the arithmetic.
The fine registers move the picture and nothing else. Writing a character still lands in a whole cell, because there is no such thing as less than a cell to write into - so a program may scroll to any pixel and the console's idea of where row three, column five is does not move underneath it. The coarse registers are the ones the console follows, and it has always followed the row.
None of the four does anything in bitmap mode, which has no map to slide.
The Character Generator:
The font and the sixteen colour schemes come from a ROM in the device. Reset copies them into video memory, and Command port 0x39 copies them again on request:
| Port | Register |
|---|---|
| 0x39 | Command. Bit 0 asks for the font back, bit 1 for the sixteen schemes. Write only. |
This used to be magic and now is not. The glyphs were written into video RAM at reset and existed nowhere else, which looked harmless until something wanted the font back: RAM does not wake up with anything in it, and a program that redefined a glyph had destroyed the only copy there was. A machine with a character generator is what the machines this one is pretending to be actually had, and the copy into RAM is now a thing the device does rather than a state it mysteriously starts in.
The RAM is still RAM. A program may overwrite every glyph and every colour and should be able to - that is what makes this a tile engine rather than a text display. What changed is that doing so is no longer a one way door.
Neither command clears what it does not own. The font writes the 135 glyphs it has and stops, so a tile a program defined above them survives; the schemes write the two entries of each of the sixteen and stop, so a program's own colours in between survive. Asking for the font back must not cost a program the tile it was drawing with.
In bitmap mode the tiles are the picture, so asking for the font there draws glyphs across the top of it. That is not a case being ignored: it is what the memory means in that mode.
A system that wants a different font still loads one over the top. The ROM is the floor rather than the policy - it is what lets a machine with no disk say that it has no disk, and what lets a program with no system behind it put readable text on a screen.
Writing On The Screen:
A console on a machine with a screen sends every byte to both, because a machine with a screen and a serial line is an ordinary machine and there is one console driving both.
At reset the font is expanded into tile memory and the palette is given sixteen ink and paper pairs. See Colour below.
The font is in ASCII order, so a byte becomes a glyph by subtracting 32. Bytes below that have no glyph and are not drawn; three of them do something instead.
| Byte | Does |
|---|---|
| 0x0A | Newline. The cursor goes to the start of the next row, and at the last row the screen scrolls instead. |
| 0x0D | Carriage return. The cursor goes to the start of the row it is on. |
| 0x08 | Backspace. The cursor steps back and rubs out what was there. |
Writing past the last column wraps to the next row, the same as a newline.
Colour:
A glyph is drawn in palette indices 0 and 1 - paper and ink - and a cell's attribute nibble adds sixteen to both. So sixteen banks is sixteen ink and paper pairs, and a text attribute system costs one nibble and no hardware at all.
Which pair the console draws in is the Attribute register, 0x06. Everything written after it is drawn that way, until it changes.
The palette a machine wakes up with is arranged so that highlighting is one bit:
| Attribute | Paper | Ink |
|---|---|---|
| 0 | Black | Grey |
| 1 to 7 | Black | Red, green, yellow, blue, magenta, cyan, white |
| 8 to 15 | The same seven and grey | Black |
So attribute XOR 8 turns any pair inside out, which is what a highlighted line wants and how the cursor is drawn. Bank 0 is grey on black, which is what plain text has always been.
That arrangement is a convention rather than a rule of the machine. A program that wants different colours writes its own palette, and one that wants thirty-two of something rather than sixteen pairs can have that too - the device only ever adds the nibble and looks the answer up.
The palette lives at 0xFC00 in video memory, four bytes an entry - red, green, blue, and one spare - so entry n begins at 0xFC00 plus n times four. Video memory belongs to the screen rather than to the program, so it is written the way every device's memory is written: registered as a bank, and reached through the memory controller.
Programs/Examples/colours.asm does all of that in eighty lines and prints the result. It shows the sixteen pairs, shows what XOR 8 does to each, and then changes one of them by writing three bytes into the palette, so that the difference between using the colours a machine wakes up with and choosing your own is visible in one program.
Moving The Cursor:
Three more registers, because that is how this machine talks to everything else.
| Port | Register |
|---|---|
| 0x03 | Cursor row. Read and write. |
| 0x04 | Cursor column. Read and write. |
| 0x05 | Command. Write 1 to clear the screen, which also puts the cursor at the top left. |
| 0x06 | Attribute. Read and write. |
A cursor is shown only when it is asked for, with bit 2 of the Control port, and status bit 4 says whether one is being shown. Off is the right default for a machine: a program painting its own screen does not want something blinking in the middle of it, and a system that reads lines from a person turns it on.
It is drawn by turning its cell inside out rather than by putting a block over it, so the character underneath stays readable - which matters to somebody editing a line. And it blinks on the machine's own clock, half a second on and half a second off, so the picture at a given cycle count is the same picture every time and a saved screen is not a matter of luck.
Both counted from zero, and both readable, which is the thing worth having: a routine that wants to put the cursor back where it found it asks where that was.
A cursor sent past the edge is clamped rather than refused. It has an obvious place to be, and stopping the machine over one would be a poor trade.
Clearing does not touch the scrollback. It clears what is on the screen, and what has already gone off the top is still in the map where the Scroll register can find it.
There is no escape sequence here, and there should not be. ANSI exists because a screen used to be on the other end of a serial line and a byte stream was the only channel there was. This screen is memory the program can already address, and reaching it by sending characters for a parser to take apart is a middleman for something the machine does better - clearing by writing 1 to a port costs one command, against a thousand cells walked one at a time.
What a program on the other end of an actual serial line sees is a different question, and the answer is that the console sends it the escapes it needs. That is the emulator bridging to a host terminal, the same job it does reading standard input, and it is not part of this machine.
Scrolling moves the video device's Scroll register and no memory at all. The row that comes into view at the bottom is cleared, because the map is a ring and it is holding whatever was there 128 rows ago. The rows that go off the top are not cleared, and that is the point: a hundred rows of what has already been said are still in the map, so a machine has scrollback without anything having to keep it.
It is one screen. A program that writes its own tiles and its own map has taken the screen, and a console still writing characters into it will scribble on what that program drew. This is not an oversight to be worked around - it is what one screen means, and it is why a program that wants the screen takes it.
Making A Noise:
Four channels on ports 0x40 to 0x4F. Each one is a whole voice - two oscillators, two envelopes, a filter and the routing between them - and it keeps its settings between notes. Channel two is channel two: a program sets up a sound once and then plays it, the same way it sets up a tile once and then places it.
Why It Is Six Ports And Not Forty:
A voice has around forty settings and there are four of them, so a port for each would spend more than half of the machine's whole port space on one device. Instead there is a selector and a value: say which channel, say which setting, write it. Three writes to change one thing.
That is the right price because of when a program pays it. Patches are loaded; notes are played. Changing a setting happens when a program starts or when an instrument changes, and three writes there costs nothing anybody can hear. Playing a note happens in the inner loop of a music routine, and that is two writes with no selector machinery at all.
Registers:
| Port | Register |
|---|---|
| 0x40 | Status. Bit 0, some channel is still sounding. |
| 0x41 | Channel, 0 to 3. Anything larger wraps, so a program cannot select a channel that is not there. |
| 0x42 | Which setting the next write to 0x43 means. |
| 0x43 | The value of that setting, for the selected channel. |
| 0x44 | Note. Writing a MIDI note number starts it: 60 is middle C, and every 12 is an octave. |
| 0x45 | Gate. Writing zero releases the note and lets it fade; writing anything else starts the last note again. |
| 0x46 | Volume, for the whole device. |
Reading 0x41, 0x42 and 0x44 gives back what is in them, so a routine can save and restore the selection around an interrupt.
The Shortest Program That Makes A Sound:
RSTA
OUTA 0x41 ; Channel 0
INIA 0d60
OUTA 0x44 ; Middle C, which starts it
Every channel arrives able to make a sound: one oscillator switched on at full gain, a plain triangle wave, an envelope that fades in and holds. Writing a note number is the whole of playing a note, and a program only reaches for the settings when it wants a different sound rather than a sound at all.
The second oscillator arrives switched off, and that is not the same as arriving silent. The
two oscillators are averaged rather than added, so switching the second one on halves the
first whatever gain it has - which is what keeps two of them from clipping, and which means
there is no setting of active that costs nothing. One oscillator is the plain case, and
asking for two is something a program says out loud:
INIA 0x15
OUTA 0x42 ; Oscillator 1, on
INIA 0x01
OUTA 0x43
INIA 0x11
OUTA 0x42 ; and how loud
INIA 0xC0
OUTA 0x43
Settings:
The high nibble says which part of the voice, the low nibble which setting of it.
| Number | Part |
|---|---|
| 0x00 - 0x0F | Oscillator 0. |
| 0x10 - 0x1F | Oscillator 1. |
| 0x20 - 0x2F | The amplitude envelope. |
| 0x30 - 0x3F | The modulation envelope. |
| 0x40 - 0x4F | The filter. |
| 0x50 | What shapes the channel's level. |
| 0x60 - 0x6F | LFO 0. |
| 0x70 - 0x7F | LFO 1. |
| Oscillator | Setting |
|---|---|
| 0 | Waveform: 0 sine, 1 triangle, 2 saw, 3 ramp, 4 pulse, 5 noise. Anything larger wraps. |
| 1 | Gain. Silent at zero, which is where it starts. |
| 2 | Pulse width, for the pulse wave. |
| 3 | Detune, centred on 128, an octave either way. A step is about nine cents. |
| 4 | Octave, centred on 128, two either way. |
| 5 | On, or off at zero. |
| 6, 7 | What modulates the pulse width, and how much. |
| 8, 9 | What modulates the detune, and how much. |
| 10, 11 | What modulates the gain, and how much. |
| Envelope | Setting |
|---|---|
| 0 | Attack. |
| 1 | Decay. |
| 2 | Sustain, the level it holds at while the note is held. |
| 3 | Release. |
| Filter | Setting |
|---|---|
| 0x40 | On, or off at zero. |
| 0x41 | Type: 0 low pass, 1 high pass, 2 band pass. Anything larger wraps. |
| 0x42 | Cutoff. |
| 0x43 | Resonance. |
| 0x44, 0x45 | What modulates the cutoff, and how much. |
| 0x46, 0x47 | What modulates the resonance, and how much. |
| LFO | Setting |
|---|---|
| 0 | On, or off at zero. |
| 1 | Waveform, from the same six. |
| 2 | Rate. |
Anywhere a setting asks what modulates something, the answer is one of these:
| Value | Source |
|---|---|
| 0 | Nothing. |
| 1 | The amplitude envelope. |
| 2 | The modulation envelope. |
| 3 | LFO 0. |
| 4 | LFO 1. |
The two LFOs belong to the device and not to a channel, so writing 0x60 to 0x7F ignores whichever channel is selected. That is what makes them useful: a vibrato that every voice shares is one wobble rather than four that drift apart.
What A Byte Means:
Everything here is a byte, and a synthesizer wants seconds and hertz. How the one becomes the other is chosen for where the useful part of the range is, not for whatever arithmetic is tidiest.
| Kind of setting | 0 to 255 becomes |
|---|---|
| Times: attack, decay, release | Nought to four seconds, squared. |
| Levels: gain, sustain, resonance, volume | Nought to the most there is, evenly. |
| Cutoff, LFO rate | 20 Hz to 20 kHz, and 0.05 Hz to 20 Hz: exponential. |
| Detune, octave, and every modulation depth | Centred on 128, so half is no change and either side is a direction. |
Times are squared because the difference between five and fifty milliseconds is the whole character of a percussive sound, and the difference between three seconds and four is nothing anybody can hear. A byte spread evenly over four seconds would spend nine tenths of itself on the part that does not matter. Cutoff and rate are exponential for the same reason, since pitch is logarithmic and so is where a filter sounds like it is.
Level:
Setting 0x50 says what shapes the channel's level, out of the same list of sources. It is normally the amplitude envelope, which is what an amplitude envelope is for, and it can be set to nothing - a channel whose level nothing shapes plays flat out until it is gated off.
That sounds like a small thing and is not. Without it the amplitude envelope is welded to the output, so an envelope routed somewhere useful - opening the filter, bending a pitch - still has to be shaped like something you would want to hear, and a snare that wants a click of filter sweep and a flat body cannot have both.
Knowing When It Has Finished:
The status port's bit 0 is set while any channel is still sounding, so a routine can wait for a sound to end rather than counting cycles.
There is one rule about when a note ends, and it is worth stating on its own because the obvious guess is wrong. A note sounds until the gate is dropped. What the envelope is doing does not come into it.
In particular, a note whose sustain is nothing goes quiet and keeps sounding. Silence and being finished look identical from outside and are not the same thing: the voice is holding at nothing, which is exactly what a held key does on any instrument. A program that plays such a note and then waits for the status bit waits for ever.
So a routine that means to wait for a sound does this, in this order: play the note, wait
however long the note is meant to last, write nothing to the gate at 0x45, and then wait for
the bit to come down - which it does when the release has finished. Programs/Examples/tune.asm
is that loop with the waiting done on the screen's frame.
The Sound Comes From The Machine's Clock:
Samples are made against cycles, not against however fast the host really ran: forty-eight thousand a second of emulated time, worked out in whole numbers so it never drifts. Three million cycles make exactly one hundred and forty-four thousand samples.
This is the same decision as the screen writing a picture out, and it buys the same thing. A
sound is something a test can compare: the same program makes the same samples every time,
on any host, at any speed, and Tests/sound.sh reads them back and measures the pitch. It
also means a machine that is paused makes no sound rather than a held note, which is right - a
stopped machine's oscillators are stopped too.
The device does not interrupt. Nothing about a note finishing needs the CPU's attention urgently enough to be worth a line, and a program that wants to play in time has the screen's frame interrupt, which is 60 a second and already there. A programmable timer is the proper answer and is a device that does not exist yet.
Asking What Is There:
A program that only ever runs on one machine can be told where everything is. A program meant to run on more than one has to ask, and the bus registry on port 0xFF is what it asks.
Write a port number to the registry to say which port you are asking about, then read to get that port's record a byte at a time:
| Byte | Meaning |
|---|---|
| 0 | The device class. Zero means there is nothing on that port. |
| 1 | Flags. Bit 0 means the device brings memory of its own. |
Reading past the end of a record gives zero, so a record can grow later without anything already written having to change. Selecting a port starts its record again from the beginning.
INIA 0x03
OUTA 0xFF ; Ask about port 3.
INA 0xFF ; A is now the class of whatever is on port 3.
The registry answers on the device's behalf and never touches it. That is the reason it exists rather than programs simply reading each port to see what answers: reading a port is a real operation with real consequences, and reading the console to find out what it is would take a character off standard input and then wait for one that may never come.
The registry is read only. A program cannot tell it that a device exists, because saying so would not make one exist, and once a program could write to it nothing reading it could tell what is really there from what has merely been claimed. Which routine handles a device is a separate question, and the vector table already answers it: installing a driver for the device on port 3 means writing hardware vector 3.
Absence describes itself. Reading a port with nothing on it gives zero, so class zero means nothing is there. A machine with no registry at all answers zero when asked about port 0xFF, which correctly says that it cannot be enumerated. A program finds out whether it can ask by asking.
One thing to be careful of: the registry remembers which port it was asked about, so an interrupt handler that enumerates in the middle of an enumeration will lose the caller's place. Enumerate with the Interrupt Flag down, or do it before any device is enabled.
Device Classes:
| Class | Device |
|---|---|
| 0x00 | Nothing. |
| 0x01 | Bus registry. |
| 0x02 | Console. |
| 0x03 | Memory controller. |
| 0x04 | The machine itself. Writing 1 to its port asks it to start over. |
| 0x05 - 0x0F | Reserved for the machine itself. |
| 0x10 | Test device, which raises its own line. |
| 0x11 | Test device, which refuses everything. |
| 0x12 | Test device, which owns memory. |
| 0x13 | Disk. |
| 0x14 | Screen. |
| 0x15 | Sound. |
| 0x16 | Timer. |
| 0x17 - 0xFF | Peripherals. |
Keeping Time:
A period, in cycles, and a bit that says when one has gone by.
Before this the only regular beat on the machine was the screen finishing a frame, and that is a clock a program borrows rather than one it sets. A frame is 16,667 cycles and not negotiable, so every duration becomes a multiple of it - and a sixteenth note at 120 beats a minute is 125,000 cycles, which is seven and a half frames. It cannot be asked for at all. The way round it is to choose a tempo whose subdivisions happen to land on whole frames, which is making the music fit the machine.
| Port | Register |
|---|---|
| 0x50 | Status. Bit 0 a period has gone by, bit 1 it is running, bit 2 it is set to interrupt. |
| 0x51 | Control. Bit 0 run, bit 1 repeat, bit 2 interrupt. |
| 0x52 - 0x54 | The period, in cycles, most significant byte first. |
The period is in cycles, because that is what everything else here is counted in: it is what the cost model counts and what a frame is measured in, so a timer counting anything else would be a second unit to remember. Twenty four bits reaches from one cycle to sixteen and a half seconds, and 120 beats a minute sits at 500,000 in the middle of it. There is no prescaler, because there is no range left for one to buy.
Starting it loads the period. Writing the control byte with the run bit already set does not, so a program that turns interrupts on half way through a period does not silently move the beat it was keeping.
With the repeat bit it reloads; without it, it stops and the status port says so. What is left over carries into the next period, so a timer asked for 1,000 cycles ticks every 1,000 and not every 1,000 plus however late anybody looked.
Reading the status is what answers it: the tick comes down when it is read, and the line with it. A program that polls is not one that will answer a handler.
; A sixteenth note at 120 beats a minute, waited for rather than counted.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54 ; 0x01E848, which is 125,000
INIA 0x07
OUTA 0x51 ; Run, repeat, interrupt
SIF
WAIT
Eight of those is one second, and a machine doing it spends 999,720 of those cycles asleep.
The Memory Controller:
SplitBit's instruction set cannot write Program Memory. That is what a Harvard machine is, and it is worth keeping true of the instructions: a machine where any instruction stream can rewrite its own code has given away the separation it was built for. The memory controller can, so writing code is a capability reached deliberately through a port rather than something every program has by accident.
It answers on ports 0xE0 to 0xEF, one register to a port.
| Port | Register |
|---|---|
| 0xE0 | SourceBank |
| 0xE1 | SourceHigh |
| 0xE2 | SourceLow |
| 0xE3 | DestBank |
| 0xE4 | DestHigh |
| 0xE5 | DestLow |
| 0xE6 | LengthHigh |
| 0xE7 | LengthLow |
| 0xE8 | Command |
| 0xE9 | Data |
| 0xEA | Status |
| 0xEB | GuardBank |
| 0xEC | GuardStartHigh |
| 0xED | GuardStartLow |
| 0xEE | GuardEndHigh |
| 0xEF | GuardEndLow |
Reading the Data port takes a byte from the source and steps the source address on. Writing to it puts a byte at the destination and steps the destination address on. Reading a run of bytes is therefore a loop over one instruction rather than four.
Status says how the last thing the controller was asked to do went. Zero means it worked; anything else is the vector it refused with.
Commands:
Writing to the Command port performs it at once. A transfer is instantaneous as far as the CPU is concerned: waiting belongs to a peripheral that has something to wait for, not to the moving of bytes.
| Value | Command | Does |
|---|---|---|
| 0x01 | Blit | Moves Length bytes from Source to Dest. Either end may be any bank, including the same one. |
| 0x02 | Fill | Writes the byte in SourceLow across Dest, Length times. SourceBank and SourceHigh mean nothing here, because a fill has nowhere to read from. |
| 0x10 | GuardOn | Raises a fence over the bank named by GuardBank, across the range in the guard registers. |
| 0x11 | GuardOff | Lowers that bank's fence. |
| 0x03 | RegisterBank | Gives the bank number in DestBank to the memory owned by the device on port SourceLow. |
Length is how many bytes, and zero means the whole 64K, since a transfer of nothing is never what anyone meant. Both commands leave the addresses past whatever they touched and leave Length as it was, so asking again carries straight on from where the last one stopped.
A blit may overlap itself. Sliding a run of bytes along inside its own bank works, rather than repeating the first byte the way a plain forward copy would.
Everything a transfer would touch is checked before any of it moves. A transfer that would run out of bank, or write somewhere it may not, is refused whole: nothing moves at all. A transfer that stopped halfway would leave memory in a state no program asked for, and the diagnostic would arrive after the damage rather than instead of it.
Filling is worth reaching for. Clearing a page with one Fill instead of a store and a loop takes about a tenth off the running time of the segmented sieve, which spends most of its life zeroing its window.
What A Transfer Costs:
A transfer does not wait on anything, but it is not free. The controller is charged for every byte it moves, and the program that asked stalls until it is done, so these are cycles out of that program's budget.
Two things set the rate. Banks are separate memories, so a move between two of them can fetch the next word while the last one is stored, and a move within a single bank cannot and costs twice as much. And the controller's path to memory is sixteen bits wide, so it moves two bytes at a time when the addresses allow.
They allow it when the source, the destination and the length are all even. A word is read at an even address and written at an even address; an odd anything would mean shifting bytes across word boundaries to line them up, which is a different machine. A misaligned transfer falls back to a byte a cycle, which is what this cost before the path was widened.
| Moving 256 bytes | Aligned | Not aligned |
|---|---|---|
| Between two banks | 129 | 257 |
| Within one bank | 257 | 513 |
| Fill | 129 | 257 |
The odd cycle in each is the pipeline filling. A fill has nothing to read, so it goes at the between-banks rate whatever bank it writes, and only its destination and length decide whether it can be paired - the byte it writes lives in SourceLow and is a value rather than an address.
The rule is visible so that a program can act on it. Aligning a buffer costs nothing and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid.
None of this changes the CPU. It still sees eight bits, a Data Pointer still addresses a byte, and no instruction means anything different than it did. What got wider is the controller's own path to the memories it moves between.
Banks:
Memory the controller can reach is divided into banks of up to 64K each, numbered 0 to 255. Program and Data are banks like any other; being 0 and 1 is the only thing special about them.
| Bank | Holds |
|---|---|
| 0 | Program Memory. |
| 1 | Data Memory. |
| 2 | The controller's own memory, which is where the bank table lives. |
| 3 and up | Registered by software, for devices that bring memory of their own. |
Banks 0 to 2 belong to the machine and cannot be handed out. Everything above them is registered by whoever enumerated the hardware, so which number a device's memory answers to is the operating system's business rather than the machine's. That is what lets a device own no banks, one, or several.
Registering asks the bus registry which ports bring memory, then names one:
INIA 0d5
OUTA 0xE3 ; The bank number being handed out.
INIA 0x12
OUTA 0xE2 ; The port that owns the memory.
INIA 0x03
OUTA 0xE8 ; RegisterBank.
How big the bank is comes from the device, not from the program. Capacity was settled when the machine was built, so a program asserting it could only ever be wrong. Registering a bank over one that already holds something is allowed: nothing was allocated that could be lost by changing your mind.
Registering fails if the number is one of the machine's own, or if the port named brings no memory. Either would put a bank in the table that leads nowhere.
A reset clears the table back to banks 0, 1 and 2. Banks are soft state, so a program that wants a device's memory registers it during setup, which is a handful of writes and needs no operating system.
Banks are reachable only through the controller. There is no bank register that changes what the CPU sees, and there never will be: a Data Pointer addresses bank 1, always, so an instruction never means something different depending on state you cannot see in the listing.
The Bank Table:
Bank 2 holds a description of every bank, eight bytes each, so bank n's record begins at n times eight.
| Byte | Holds |
|---|---|
| 0 | Flags: bit 0 present, bit 1 read only, bit 2 fenced. |
| 1 | The port that owns it, or 0xFF for the machine itself. |
| 2 and 3 | Capacity, where zero means the whole 64K. |
| 4 and 5 | The first guarded address. |
| 6 and 7 | The last guarded address. |
A program reads it the way it reads anything else, by pointing the controller at bank 2. There is no separate query for it, and nothing to interfere with an enumeration already in progress.
Bank 2 is read only. That is what keeps the table something only the controller changes, and the table is what every transfer routes through: corrupt it and everything afterwards goes somewhere arbitrary. What the table holds is a description rather than the machinery, so writing to it could never redirect a bank even if it were allowed.
The Fence:
Every bank may have one guarded range. A write that lands inside it is refused, and so is any transfer that so much as overlaps it: a blit that clipped the edge would otherwise do the part that fitted and leave the rest undone.
Set GuardBank to say which bank, put the first and last guarded addresses in the guard registers, and write GuardOn. GuardOff lowers it again.
Any program may lower any fence. This is a fence rather than a wall, and nobody is ever told no. What it stops is walking into something by accident. A program that genuinely means to write there lowers the fence first, which costs one instruction and says plainly in the listing what it intended.
A fence guards writing, not reading. Whatever is behind it can still be read, because a debugger has to be able to see what it is protecting.
A range that ends before it starts is refused. No address could be inside it, so it would catch nothing while looking like it caught something, and a program that raised one would believe it was protected when it was not.
A raised fence is published in the bank table along with everything else about the bank, so a program can see what is guarded without having to remember.
What The Fence Does Not Do:
It is worth being plain about the limit, because the name suggests more than it delivers.
Every write to Program Memory goes through the controller, so a fence over bank 0 catches all of them. That is what it is for: code that gets walked over is otherwise discovered much later, when the wreckage is finally executed, thousands of cycles from the mistake and with the evidence gone. A fence turns that into a fault at the instruction responsible, with the address still in the controller's registers to be read.
Data Memory is different. STA, STB, STQ and STD write bank 1 directly and never go near the controller, so a runaway Data Pointer walking over a program's variables is not caught and cannot be. Catching it would mean putting the check inside the CPU's store path, which would make an instruction behave differently depending on state that does not appear in the listing. That is the thing this machine does not do.
So the fence protects code from a mistaken loader. It is not general memory protection, and a program should not be written as though it were.
Loading A Program:
Everything the controller does adds up to one thing a SplitBit machine could not do before: run code that was not in the boot image it started from.
The sequence is short. Put the bytes of a routine somewhere, blit them into Program Memory, write their address into a vector, and call it.
; Vector 20 lives at 0xFC00 plus twice twenty, which is 0xFC28.
RSTA
OUTA 0xE3 ; DestBank = 0, Program Memory.
INIA 0xFC
OUTA 0xE4
INIA 0x28
OUTA 0xE5
OUTB 0xE9 ; The handler's address, high byte then low.
OUTA 0xE9
SWI 0d20
A vector that is empty when a program starts and holds a working handler by the time it is called is the whole reason this device exists. A vector table nothing can write is not really a vector table.
What Loading Does Not Solve:
A routine that has been moved works at its new address only if it does not contain any addresses of its own.
INIA, OUTA and RETI name no places, so a routine built only from those runs correctly wherever it is put. A branch, a CALL, or a SETD is different: each of them carries an address that was decided when it was assembled, and that address is wrong everywhere except where it was assembled for. Nothing in SplitBit adjusts them.
So loading works and relocating does not exist. A program can be put into memory at the address it was built for, and it will run. Putting it anywhere else is an unsolved problem, and a real one, because a machine that can only ever load a program to one place cannot load two programs at once.
Being Turned Away:
A bank knows how big it is, so an access past the end of one is a BankFault rather than a read of whatever happens to be next. Naming a bank with nothing registered in it is the same fault.
A write the controller will not perform is a GuardViolation. There are two reasons for one: the bank is read only, or the write touches a fence that has been raised over it.
Both arrive at the instruction that asked, so a handler sees which one it was. A handler that means to carry on past it steps the saved address on by two, since the input and output instructions are an opcode and a port.
Storage:
The disk is a block device. It knows numbered blocks of 256 bytes and has never heard of a file. A filesystem is software this machine runs, not something done on its behalf: a disk that understood filenames would be the emulator doing the work while the machine pretended it had.
A block is a page, so a block number is a whole 16 bit address and the arithmetic never needs a multiply. Sixteen megabytes of them is absurd for this machine, which is the point: there is room for whatever a filesystem grows into.
| Port | Register |
|---|---|
| 0x20 | BlockHigh |
| 0x21 | BlockLow |
| 0x22 | Command. 0x01 reads, 0x02 writes. |
| 0x23 | Status. Bit 0 busy, bit 1 the last operation failed, bit 2 the disk is write protected. |
The disk owns one block of memory, its buffer. Reading fills it and writing takes what is in it. Like any memory a device brings, it is unreachable until it has been registered as a bank, and reachable only through the memory controller even then. The CPU never touches it directly.
A device that answers on more than one port raises its line on the first of them, so the disk interrupts on 0x20.
Several Disks:
One controller with four drives, not four devices, and the instruction set is the reason.
A port is an immediate byte inside the OUT that names it, so a program cannot compute one -
the disk on port 0x20 plus drive times four is not something this machine can say. Two disks
as two devices would mean a branch on the drive number in every place a program touches a
disk port. So the drive is a register, which is what a floppy controller has always been.
| Port | Register |
|---|---|
| 0x24 | Drive. Which one the block, command and status registers refer to. Reads back. |
| 0x25 | Drives, read only. How many are plugged in. |
| 0x26 | What the selected drive is, read only. Bit 0: its contents do not survive the machine stopping. |
| 0x27, 0x28 | How many blocks the selected drive has, read only. |
A Drive Made Of Memory:
A drive may have memory behind it instead of a file. It selects, reads, writes and has a size like any other, and a filesystem on it is a filesystem - a program cannot tell the difference except by how fast it was. What it has not got is anything that survives the machine stopping.
That difference is the one thing a system cannot work out for itself, because an empty disk and a volatile disk look identical from outside. So the machine says it, in bit 0 of 0x26, and says nothing whatever about filesystems.
Which is the whole point of saying it that way. The bit is what separates a drive a system may format on sight from one it must not: an unformatted floppy somebody put in deliberately is not an invitation, while an unformatted drive made of memory never had anything to lose. A system reads the bit and draws its own conclusion - and a system that would rather have a different filesystem entirely reads the same bit and writes whatever it likes. The machine supplies blocks. Bringing them up is the system's job.
The size registers exist for the same reason. A superblock states a disk's size too, and that is no use at all on a disk which has not got one yet.
The block, command and status registers, and the single buffer, all belong to whichever drive is selected. A program that changes drives is holding a buffer that no longer contains what it thought, and has to say so to itself - the controller cannot know what the program believed.
A drive that is not there is refused rather than wrapped: writing 9 to the drive register leaves the selection where it was, and reading the register says so. Wrapping would mean a program asking for a drive this machine does not have quietly reading the one it does.
Selecting an empty drive is allowed, because a controller has its drives whether or not
there are disks in them. Reads from one fail with the error bit, which is what an empty drive
should do. Drives says how many have disks; the drive register accepts any of the four.
Changing drives finishes whatever the drive being left was in the middle of. A transfer waits for the clock, so one may be owed at any moment, and running it against the disk that is arriving instead of the one that asked for it would be a fault with no owner.
Waiting:
A command returns at once and the line goes up when the block has moved. Status bit 0 says the disk is still working.
That bit always reads clear here, because the host finishes before the next instruction does. Honour it anyway. A machine with a slower disk would set it, and a program written to ignore it would work on this one and fail on that one. Waiting for the line is the other way, and the right one once vectors are installed; the bit is what a bootstrap polls before there are any.
Write Protection:
A disk may be read only, and the bar is in the device. Status bit 2 says so.
That bit is not like the two below it. Busy and error describe the last operation; protection describes the medium, so it reads true before anything has been asked of the disk at all. A program can find out whether it can write without having to try and be refused.
The bar being in the device is the point of it. A flag in a superblock can be got around by writing blocks directly, and this cannot be got around at all. It is the tab on the side of a floppy rather than a note asking politely.
A disk is read only if it was attached that way, or if the host will not let its image be written. The machine cannot tell those two apart, and has no reason to.
Reading a protected disk is ordinary and does not disturb the bit.
When It Does Not Work:
Status bit 1 says the last operation failed: there is no disk, or the block asked for is not on it.
A disk error is not a fault. Faults on this machine mean it cannot continue, and a read that fails is an ordinary thing that happens to working programs on failing media. It is reported so that a program can cope with it, rather than stopping the machine and taking the choice away.