An LFO belongs to the DEVICE and not to a channel. There are two of them against four voices, and a patch carries LFO settings the way it carries everything else - so whichever patch was loaded last owns both of them, for every voice at once. The warning's trill is a saw LFO on the pitch. The patches on channel three, the bang and the latch, carry an LFO that is switched off, and they load at the moment they are used. So the first landing, docking or crash of a run took the trill away and left a plain tone, and it was right again next time the machine started. Correct until something unrelated plays is the worst shape a fault can have. The same thing had already happened silently at startup: the instruments were set up in order, so the warning's LFO settings, written last, sat on top of the rumble's and the rumble never had its own at all. So nothing is set up once any more. Each sound loads its patch immediately before its note - forty-odd writes at a moment already making a sound - and is then whatever its patch says, whatever played before it. Measured after a landing: 1056, 660, 516 hertz, then up to 1698 and down again. Two sweeps of the saw, which is the trill. What it does not fix, because it cannot: two sounds overlapping still share the LFOs, so a warning going off mid-burn re-tunes the rumble for as long as it lasts. With two between four that is the device. Three checks at the device level, where the trap can be stated exactly: a routed LFO bends a pitch (184 Hz against the 262 the note asked for), another channel's patch takes it away (272, the note itself), and saying it again gets it back (184). Written up in the Programming Manual beside the LFO mode, since the next program to want two sounds will meet it too.
115 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 |
| 0x60 - 0x6F | The game controllers. See Controllers. Four pads, polled, reporting what is held. | 0x17 |
| 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:
Two banks, brought by the device and reached only through the memory controller, like the disk's buffer. They keep what is in them between frames, so a program writes the part that changed and the rest stays as it was.
Two rather than one because the two halves of a screen are written at completely different rates. The atlas is tiles and colours: put there when a program loads and then left alone. The screen is the map: rewritten as often as anything moves.
| Bank | Address | Holds |
|---|---|---|
| Atlas | 0x0000 - 0x3FFF | Tile page 0. 256 tiles of 8 by 8, one byte a pixel, so tile n begins at n times 64. |
| Atlas | 0x4000 - 0x7FFF | Tile page 1. |
| Atlas | 0x8000 - 0xBFFF | Tile page 2. |
| Atlas | 0xC000 - 0xCFFF | The sprite table. 256 entries of sixteen bytes - and tiles 0 to 63 of page 3. |
| Atlas | 0xD000 - 0xD27F | The depth buffer. One byte a screen column - and tiles 64 to 73 of page 3. |
| Atlas | 0xD280 - 0xFBFF | Free - and tiles 74 to 239 of page 3. |
| Atlas | 0xFC00 - 0xFFFF | The palette. 256 entries of four bytes: red, green, blue, and one unused - and tiles 240 to 255 of page 3. |
| Screen | 0x0000 - 0x3FFF | Free in a tile mode. |
| Screen | 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. |
| Screen | 0xC000 - 0xFFFF | The window. 64 rows of cells that do not scroll. |
| Screen | 0x0000 - 0xF9FF | In bitmap mode, the picture instead: 64,000 bytes, one to a pixel. |
There are two screen banks and one atlas, laid out identically, and the device shows one screen at a time. See Two Screens below.
The bitmap is the same memory as the map, which is what shared video memory has always been. Going to bitmap mode does not clear the text screen - it stops calling it one, and coming back finds the map holding whatever the picture put there.
What a picture no longer costs is the font. The tiles and the palette are in the other bank, where a bitmap cannot reach them, so a program can draw a picture and then put readable text back on the screen without asking the character generator for its glyphs again. While the two shared a bank, drawing anything destroyed them.
The palette is in the atlas, at the top and out of the way, because it is written when a program loads rather than per frame - and because being out of the screen bank is what leaves a bitmap the whole of one.
Which bank is which is a property of the address, never of the mode: tiles and the palette are always in the atlas, the map and a bitmap always in the screen.
Naming Them:
A bank is registered by naming the port that owns it, so a device with two banks needs two ports that own memory. The screen has them:
| Port | Owns |
|---|---|
| 0x30 | The atlas. |
| 0x3A | Screen 0. |
| 0x3B | Screen 1. |
Nothing is read or written at 0x3A or 0x3B - they are names for banks, and the bus registry is where a program finds out they bring one. Asking the registry about the block gives an honest answer: those three ports say they bring memory and the other thirteen say they do not.
Two Screens:
There are two screen banks and the device shows one of them. Port 0x3C says which, 0 or 1, and reads back what it was told; a screen that does not exist is not taken, the same as a mode that does not exist.
That is a back buffer: a whole screen's worth of map written where nobody can see it, and then shown all at once. A screen drawn where it can be seen is seen half drawn, and a program that moves forty things and rewrites the map underneath them is wrong for as long as it takes to put them all right - which at a megahertz is long enough to look at.
One register is enough, where real hardware needed two. The other said which screen the CPU's window pointed at. There is no window here: a program reaches a bank through the memory controller by its number, so writing to the screen that is not being shown is a matter of naming its bank, and the device never has to be told.
A flip cannot tear. A frame is drawn from one bank in one go, so a flip either happened before that frame or it happens before the next one; there is no state of having flipped halfway. The machines this one imitates had to catch the few lines between one frame and the next to do the swap in.
The console draws into whichever screen is displayed, rather than into one of its own. A game that flipped and then faulted needs the message to land where somebody can read it, and the console has no way of knowing that happened.
What a second screen costs is a whole bank of somebody's memory, and nothing else: it is memory the device brought, so a program that wants it registers it and a program that does not never pays for it. Programs/CosmOS/Apps/Flip.asm is the shortest thing to read that uses one.
The base port owns the atlas because tiles have been at 0x0000 since there was a screen at all. Programs/Examples/picture.asm registers both, and is the shortest thing to read that does.
Registering the second one moves DestBank, which is worth saying because it is easy to be caught by: RegisterBank takes the number being handed out in DestBank, so a program that registers two banks and then writes without setting DestBank again writes into the second one.
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.
Sprites:
Things that move without the map moving. A cell is where it is; something between two cells meant rewriting both of them, and something moving a pixel at a time meant rewriting them sixty times a second - which is affordable for one thing and not for twenty. A sprite is put at a pixel, and the device draws it over whatever is behind.
A sprite's attribute is a cell's attribute, read the same way and by the same code - which is what lets one piece of art be a wall in one place and a moving thing in another with nothing rewritten.
A sprite is m by n tiles, taken in reading order from one index. That is the decision the rest follows from: it needs no pixel format of its own, no second kind of memory, and nothing its art can be that the map could not also show. A 16 by 16 character is four tiles, and a program that wants the same picture in the background just names the same four.
The table is 256 entries of 16 bytes at 0xC000 in the atlas. Sixteen so that entry n begins at n times sixteen, which is a shift - the same reason a palette entry is four bytes.
| Byte | Holds |
|---|---|
| 0 | The top left tile, in the page its attribute names. The rest follow it in reading order, wrapping at 255 inside that page. |
| 1 | Attribute, which means exactly what a cell's does: low nibble the colour scheme, bits 4 and 5 the tile page. |
| 2, 3 | X, low byte first, signed. |
| 4, 5 | Y, the same. |
| 6 | Size: tiles across in the high nibble, tiles down in the low. |
| 7 | Flags. Bit 0 mirrors it, bit 1 turns it over, bit 2 puts it behind. |
| 8, 9 | Target width in pixels. Zero means the natural width, eight times the tiles across. |
| 10, 11 | Target height, the same. |
| 12 | Depth. Zero means no depth test. |
| 13 - 15 | Reserved. Leave at zero. |
The position is signed and sixteen bits because the larger mode is 640 by 400, so neither axis fits in a byte - and because a sprite has to be able to sit half off the left or the top rather than appearing whole at the edge.
A pixel of zero is not drawn. Without that every sprite is a rectangle. It is tested before the attribute is added, so a hole is a property of the art rather than of the colour scheme: a sprite drawn in indices 1 to 15 is transparent in the same places in all sixteen.
The same rule read the other way is what behind means. A sprite marked behind draws only where the background pixel was zero, so a thing can walk behind a pillar and in front of the floor in the same frame. One rule, applied to whichever layer is in front.
A sprite of no width or no height draws nothing, and that is the off switch: it saves a flag, it is per sprite rather than a global the whole table shares, and it means the table is already off when the machine starts, since the atlas wakes up cleared. Note that this is deliberately the opposite of what a length of zero means to the memory controller. The reason is the same both times - moving no bytes is a useless thing to ask for, so zero was free to mean 64K there, and drawing no sprite is the commonest state in this table, so zero has to mean nothing here.
All of them are drawn, every frame. Sprites here cannot flicker. Real machines dropped them per scanline because they had a fixed number of shift registers and a fixed time to fill them; this has a loop. The limit is how many entries the table has, which is a constant a program can count on rather than something that depends on what it happens to be drawing. Where two overlap, the lower number is in front.
Sprites are drawn over a bitmap as readily as over a map. A bitmap is what a program draws once and leaves; there is no reason the mode that cannot afford to redraw itself should be the one that cannot have things moving on it.
Scaling:
Bytes 8 to 11 say how big to draw it, in pixels, and the device stretches the m by n tiles to fill that. Zero on an axis means the natural size, so every sprite written before scaling existed still means what it meant, and a thing drawn at the size it was drawn at costs nothing to say.
A target in pixels rather than a multiplier, which is the whole of why this is usable here. A billboard at distance d wants to be k/d pixels tall, and that is a number a program has anyway - out of a lookup table, most likely. A multiplier would have to be a fixed-point fraction, arrived at by dividing, and this CPU cannot divide.
The two axes are independent, so a sprite can be stretched one way and not the other. That shape - one tile wide at its own size, stretched to whatever height a distance says - is a wall column in a pseudo-3D game, and it is the reason such a game is possible at all on this machine. Drawing 640 by 400 pixels of wall from the CPU is 256,000 writes, which is fifteen frames of cycles for one frame of screen. Writing sixteen bytes a column and letting the device do the pixels is about three thousand.
Depth:
Byte 12 says how far away a sprite is, and the depth buffer at 0xD000 says how far away the scenery is: one byte a screen column, written by the program. A sprite pixel is drawn only in the columns it is in front of.
Zero in a sprite's depth means no depth test at all, which is what every ordinary sprite wants and what a cleared table already says. Zero in a column means nothing is there, so a program that never writes the buffer has one of zeroes and every sprite draws - which is exactly the behaviour there was before the buffer existed.
Per column, and that is the point. A billboard can be nearer than the wall at one end of itself and further at the other, and no ordering of the sprite table can say that. Table order settles sprites against each other; the buffer settles them against the scenery.
The buffer belongs to the program. It is not cleared between frames, because a program that draws scenery rewrites all of it every frame anyway.
Programs/CosmOS/Apps/Sprite.asm moves one across the shell's own text without writing a byte of the map.
The Window:
A layer that does not scroll. The map moves and this does not, which the map alone cannot express: the scroll registers move all of it, so a score printed into the map is a score that slides away, and one printed into whichever rows the view happens to be showing jumps a pixel at a time as the fine offset changes.
Port 0x3D is how many screen rows tall it is and 0x3E is which row it starts at. Zero tall is no window, so a cleared screen has none and every program written before it existed means what it meant. A start row is a register because a status bar along the bottom is as common as one along the top.
Its cells are at 0xC000 in the screen bank, 64 rows of 256 bytes, and a cell means exactly what a map cell means - same tiles, same pages, same colour schemes. A window cell is at a screen position, where a map cell is at a position in a world the screen is looking at part of; keeping those two coordinate systems apart is the whole of the feature.
It has its own memory, and that is the argument for it. The cheaper design draws the top rows of the map without the scroll applied, which needs no new memory at all - and makes those rows part of the playfield's ring, so a game that scrolls vertically has to route its world around its own scoreboard for ever. The point of a status bar is that it is not somewhere in the level.
Being in the screen bank means it is per screen: flipping to the other buffer flips the status bar with it, which is what a double-buffered game wants and would be surprising the other way round.
It is drawn over everything, sprites included. A sprite that could cover the fuel gauge would be a bug in every game that had both.
It is a tile-mode layer. In bitmap mode there is nothing to draw it from - the picture is using that memory - so a bitmap program that wants something pinned to the screen uses sprites, which are in screen coordinates for the same reason.
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.
Bits 4 and 5 say which page of tiles the number is in. A tile number is a byte and a byte reaches 256, which is not many once a font has taken 135 of them and a game wants a character, a background and a wall. Two bits that were already being written on every cell reach 1024.
Four pages of 16K is 64K, which is the whole atlas, so the fourth page is the memory the sprite table and the palette are in. That is not a hole in the design; it is the same answer shared video memory has always given. The atlas is 1024 tiles, and what a program spends on sprites and colours comes out of them. A program that wants no sprites may use page 3 for art, and one that wants sprites has 768 tiles and knows why.
The page is a property of the cell, not a mode, so one screen can show tiles from all four pages at once and a program never has to decide which page it is "in".
Bits 6 and 7 are still reserved and should be left at zero.
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. |
| 0x39 | Command. Bit 0 copies the font back, bit 1 the sixteen colour schemes. |
| 0x3A | Owns screen 0. Not read or written. |
| 0x3B | Owns screen 1. Not read or written. |
| 0x3C | Display. Which of the two screens is being shown. |
| 0x3D | Window height, in rows. Zero is no window. |
| 0x3E | Window start, which screen row it begins at. |
| 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. |
| 0x51 | Whether a note waits to be let go of. |
| 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. |
| 3 | 0 free, 1 starts over with every voice. See The Same Sound Twice. |
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.
Struck Or Held:
Setting 0x51 says whether a note waits to be let go of: 0 gated, which is how it has always been, and 1 triggered.
A gated voice lasts as long as something keeps hold of it. Writing the gate port ends it, and the release begins there. That is what a keyboard is, and it is right for anything a player holds down.
A triggered voice is struck and then plays its own length. Nothing has to remember to end it, and the gate port need never be written at all. Sustain and release have no meaning in one, because both of them are answers to a question about a key that is not being asked - so in a triggered voice the decay runs to nothing rather than stopping at the sustain level, or a patch with any sustain at all would hold the voice open for ever.
A game is nearly all one-shots. A bang, a pickup, a door: not one of them wants its length decided by how long a note was held, and every one of them would otherwise need a program to come back later and let go of it.
The Same Sound Twice:
Setting 3 of either LFO says whether it starts over when a voice does: 0 free, which is how it has always been, and 1 retriggered.
A free LFO is one cycle running under everything, which is what vibrato across a held chord wants. A retriggered one starts at the beginning of its shape every time a voice begins, and the cycle belongs to the voice rather than to the device - so retriggering costs nothing to a channel not using it.
This is the other half of a repeatable sound effect, and neither half is sufficient alone. A triggered voice re-arms its oscillators, so a hit begins at the same point in its waveform every time and a noise source draws the same noise. But an LFO left free is wherever the wall clock happened to leave it, so the same drum caught at a different moment is still a different drum. Set both and a one-shot is the same one-shot, sample for sample.
The consequence is worth knowing rather than fixing: a retriggered noise source is bit repeatable, so every hit is literally the same noise, the way a sampler is. On a hi-hat that can read as machine-gunny. Where variation is wanted, leave that LFO or that voice free.
A Warning About Sharing Them:
An LFO belongs to the device and not to a channel. There are two of them against four voices, so setting one from a patch meant for channel three changes what channel nought hears, immediately and for as long as nothing sets it back.
That matters because a patch naturally carries LFO settings along with everything else - it is one instrument, and its LFO is part of how it sounds. A program that loads its instruments once at startup therefore ends up with whichever of them was written last, for all of them.
The failure this produces is unpleasant to diagnose, because it is intermittent by nature: a sound is correct until some unrelated thing plays, and correct again next time the machine starts. A game here lost a warning's trill after the first landing of each run, because the landing's patch happened to carry an LFO that was switched off.
Load a patch immediately before the note that needs it. A patch is forty-odd writes and this costs nothing at a moment that is already making a sound. What it cannot fix is two sounds overlapping - they still share the LFOs, and with two between four voices that is in the nature of the device.
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 | Game controllers. |
| 0x17 - 0xFF | Peripherals. |
Controllers:
Four pads on ports 0x60 to 0x6F. Each one is one byte, read, saying what is held right now.
| Port | Holds |
|---|---|
| 0x60 - 0x63 | Pads 0 to 3. |
| 0x64 | Which pads are there, one bit each. |
| 0x65 - 0x6F | Reserved. |
| Bit | Button |
|---|---|
| 0x01 | Right |
| 0x02 | Left |
| 0x04 | Down |
| 0x08 | Up |
| 0x10 | A |
| 0x20 | B |
| 0x40 | Start |
| 0x80 | Select |
The four directions are the low nibble, so which way is an AND with 0x0F and needs no shifting. The four buttons are the high nibble for the same reason.
Why This Is Not The Console:
The console says which key went down. That is the right shape for typing and the wrong one for playing: a game wants to know what is being held, this frame, possibly several things at once, and a stream of presses cannot say that. A key that is down and staying down sends nothing at all.
A pad reports a level rather than an event. One read gives every button at once, holding is the natural thing to express, and two directions together cost nothing. Reading does not consume it - a game may ask twice in a frame and be told the same thing both times, which an event queue cannot promise.
A pad is sampled once a frame, and what it reports does not change in between. That is not an implementation detail: what is watching a real controller runs on its own clock - a window polls its keyboard once a host frame, which is not a machine frame - and read straight through, a pad's value could change in the middle of a frame and a program asking twice would get two answers. Real hardware latches a controller once a frame for the same reason.
Key-up on the console would have been the other way to do it, and it was rejected: a terminal hands over characters and can never report a key coming up however it is asked, so it would have been a thing that worked behind a window and silently did not down a wire. A separate device can honestly say it is not there.
They never interrupt. A game polls once a frame because that is when it draws, and an interrupt for every button would be exactly the event model a pad exists to avoid.
When There Is No Pad:
A pad that is not there reads as nothing held, which is the same as a pad nobody is touching. The difference matters only to whoever wants to explain it, so 0x64 says which are really there and a game can ask for a controller rather than sitting silent while somebody presses things at it.
Programs/testPrograms/padTest.asm reads one twice a frame to show that looking does not take it away.
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.