Commit Graph
27 Commits
Author SHA1 Message Date
AnachronautandClaude Opus 5 000a6d39cb Somewhere to send the fault about there being nowhere to send it
Dispatching through a vector with nothing in it was the one fault this machine
could not hand over, because the thing that would hand it over is the thing that
has just found nothing to hand it to. It stopped the machine and no program
could do anything about it - so calling a service the system does not implement
was fatal, and that is an ordinary mistake to make.

Two new fault vectors: 5 when a software vector was empty, 6 when a device
interrupted and its hardware entry was. Separate, because they are separate
mistakes with separate fixes - one is a program calling something that is not
there, the other a program that asked to be interrupted and forgot the handler.

WHICH ENTRY WAS EMPTY ARRIVES IN Q, and it is the only thing on this machine a
handler is given in a register. 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 no new state at all,
because the frame already saved the Q the interrupted program had and RETI puts
it back.

The escalation happens once. If vector 5 or 6 is itself empty the machine stops
the way it always did, having genuinely run out of places to go.

swiFaultTest is what guards that, and it was written long before any of this: it
installs nothing, so it must still get the old halt. Breaking the escalation
fails the two new tests and not that one; making the escalation unbounded fails
that one and not the two new ones. Each break fails exactly the half it belongs
to.

noDeviceTest is fed no input on purpose. The console raises its line once when
input ENDS as well as when a byte arrives - which exists so a program driven by
interrupts is told when nothing more is coming - so with no input at all, that
end is what turns up.

Groundwork for CosmOS's fault screen, which wanted to catch these two and could
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 14:40:53 -04:00
AnachronautandClaude Opus 5 dae3455da0 Give the CPU a bus, so that there can be more than one
The first piece of the peripheral core, and it changes no behaviour: the machine still has
exactly one processor, and every one of the 169 tests still passes. What changes is that the
code has stopped assuming so.

FIVE THINGS A CPU ASKS OF THE WORLD OUTSIDE ITSELF, and every one of them was a call to a
function there was exactly one of: the port handlers, what the controller spent moving memory,
what was spent stopped waiting on a device, and the two that work the interrupt lines. Fine
for a machine with one processor and wrong for a machine with two, because A PERIPHERAL CORE'S
BUS IS ITS OWN - it sees the devices its own device gives it, raises its own lines, and stalls
on its own controller, none of which are the host's.

They are gathered into a Bus that the CPU holds a pointer to, rather than threaded through as
a bus number, because a bus is something a device PROVIDES. A device that provides one should
hand over the answers rather than be looked up by an index somebody else has to keep right.
initializeCPU puts the machine's own there, which is what every processor was on when there
could only be one, so nothing at any call site moved.

And shiftRegister is a local now. It always was one in effect - written and read inside a
single instruction and never carried to the next - but it sat at file scope, where a second
processor would have shared it. Two cores taking each other's shift halfway through an SHL is
a poor thing to discover later, and it cost two braces to make impossible.

Still to come on this rung: the interrupt bitmap, which is one file-scope array indexed by
port for the whole machine; a controller each; and the interleaving rule, which has to be
written into the manual as machine behaviour rather than left as something the emulator does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:11:54 -04:00
AnachronautandClaude Opus 5 ff4b025058 Make the cursor blink while the machine is waiting, and show how the palette works
THE CURSOR DID NOT BLINK, and the reason is worth stating: it blinks on the machine's clock,
and the machine's clock had stopped. A console waiting on a key stops the CPU, so no cycles
passed, so the phase never moved - and the one moment somebody is looking at a cursor is the
moment they are being asked to type.

Waiting is now charged as IDLE CYCLES, which is what they were built for: a machine stopped
on a device is not using memory, the same distinction WAIT makes, arrived at from the other
direction. And the devices are told as it happens rather than when the instruction finally
finishes, because a display controller does not stop blinking because the processor is
waiting on a keyboard, any more than a disk stops turning.

A keyboard file can now say NOTHING happened. A zero is a byte no keyboard sends, so it is
free to mean "a moment went by with nobody typing" - which is the commonest thing behind a
window and the only thing a file otherwise could not express. That unlocked the whole waiting
path: three checks that the cursor is lit, then dark half a second later, then lit again,
which is what blinking is.

And Programs/Examples/colours.asm, because the palette had nowhere a newcomer could read it.
It prints the sixteen pairs, prints each one again turned inside out, and then CHANGES ONE by
writing three bytes into the palette - so the difference between using the colours a machine
wakes up with and choosing your own is visible in one program. Its header explains what a
cell is, what the attribute nibble does, why palette entries are four bytes rather than
three, and why video memory has to be reached through the controller.

The manual now says where the palette lives and points at it.

SplitLint found a redundant RSTA in the example, which was worth acting on rather than
suppressing: the zero was already in A from the mode write two lines up, and saying so in a
comment teaches that SETD does not touch A, which is a thing worth knowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 08:26:15 -04:00
Anachronaut c8c9f0b363 SRET: a handler answers the way a subroutine does
CALL saves A, B and Data Pointers 0 to 2 and nothing else, which is exactly
why Q and DP3 are how a subroutine hands something back. An interrupt saves
all of it, so a service with an answer had to reach into its own frame and
un-save two fields by hand:

  MVSD.2
  DPUP.2 0d02           ; the saved Q, by an offset it had to know
  STA.2
  RETI

Thirty places in CosmOS did that. Every one knew the frame's layout by
heart, and all thirty would have gone quietly wrong the day the frame
gained a field - the same duplicated fact this project keeps being bitten
by, except duplicated into thirty places AND into the CPU.

SRET is 0x76, in the seat the block split left for it. It is RETI's frame
with RET's rule applied: A, B and DP0 to DP2 come back, the saved Q and DP3
are dropped, and the Interrupt Flag is restored from the frame - only that
bit, so carry survives a service the way it survives a call, and there is
one rule rather than two. RETI stays exactly as it was: a hardware handler
has nothing to say and must leave no trace.

CosmOS is 10,969 bytes against 11,122, and no handler knows a frame offset.

TWO MISTAKES WORTH RECORDING, both mine, both caught by tests.

The first conversion matched STA.2 with a regular expression that did not
allow a trailing comment, so it ran past the end of one handler and into
the next. The second understood the pattern and still got it wrong: the old
frame write carried the answer from A into the saved Q slot, so simply
deleting the write left Q holding whatever it happened to hold. Services
that answer by calling something were fine - Q already had it - and
services that set A directly silently reported success for every failure.
cosmosCwd is what noticed, by saying "cannot go there" about a directory
that was there. Sixteen handlers move the answer into Q now.

Seven MVQA went with it. They copied Q into A so the frame write could
carry it; SRET puts A back, so they moved a value nobody would ever read.
2026-08-27 18:18:36 -04:00
Anachronaut cd5f548736 Move the opcode map: nothing in 0x0X, and room for a return variant
Three blocks move and nothing else changes. Branches take 0x60, subroutines
take 0x70, and the ALU moves up into the 0x10 block the two of them used to
share. Order within each block is preserved exactly - this relocates them,
it does not rethink them.

WHAT IT BUYS IS AN EMPTY 0x00 TO 0x0F. Program Memory that was never
written, or a load that stopped part way and left zeroes in its tail, used
to read as a long run of ADDs: the machine carried on through them, arrived
somewhere unpredictable, and whatever broke there was a long way from the
byte that caused it. Now it faults where it is met:

  Fault: 0x00 at Program Address 0x0004 is not an instruction.

That is the address of the byte after the last real instruction, which is
the difference between a diagnosis and a search. Reserving the whole nibble
rather than just 0x00 means a run into blank memory faults wherever it
starts rather than only when it lands on the right byte. runOffTest records
it, and the block is left empty for whatever turns out to want it.

The other half is room: branches and subroutines had filled 0x10 to 0x1F
between them, so a service return that keeps Q and DP3 had nowhere to sit
next to its family. It has 0x76 waiting now.

Five places wrote an opcode down that the scripted remap did not reach, and
four of them were found by tests rather than by looking:

- secondPass.c lists which opcodes take an address, and firstPass.c knows
  SWI by number. Missing those made XOR read as a branch.
- Asm.asm knows SWI by number too, being the other assembler. Missing it
  made the native and host assemblers disagree byte for byte, which is
  exactly the check that exists to catch a thing known in two places.
- loaderTest.asm carries a hand written payload, and its RETI was 0x19. To
  the assembler those are numbers and to the program they are data, so
  nothing but running it could notice. It says so in a comment now.
- The Assembler Manual prints the bytes hello.asm assembles to, and two of
  them were branches.

The monitor's recorded disassembly moved by exactly the bytes it should:
18 became 72 wherever SWI appears, with SETD and INIB untouched and every
disassembled line still reading the same.
2026-08-27 18:05:54 -04:00
Anachronaut c3188ed657 Seventy becomes seventy one: a machine that can wait
HALT is terminal - stepCPU returns at once when the Halt Flag is up, so a
halted machine does not execute, service devices, or take an interrupt -
and that has to stay true, because every test ends with a halt and "halted"
is how a program says it has finished. The consequence was that SplitBit
had no way to wait at all. Every wait was a spin, and a spin is bus
traffic: 11.5% of Type over a 14K file on a disk of ten thousand cycles,
after read-ahead had already hidden three quarters of the latency.

WAIT is 0xFE, one byte, no operands, sitting under HALT where the
instruction that almost stops the machine belongs. Three decisions in it:

- A line already standing means there is nothing to wait for, so WAIT does
  nothing. That is what makes test-then-wait race-free.
- Any line ends the wait, masked or not, so a program can sleep on a device
  it has no handler for and read its status afterwards. Masking says who
  answers a request, not whether it happened.
- A line that wakes the CPU without being dispatched is taken down by the
  WAIT. Left standing it would be found by the next WAIT, which would
  return at once - the program would spin exactly as before while looking
  as though it slept.

Waiting is NOT a Status bit, and that is the trap avoided rather than a
gap: Status rides into the interrupt frame and comes back out, so a machine
interrupted mid-wait would return from its handler still waiting, and wait
again for what it had already been given. An internal field instead.

Idle cycles are counted apart from bus cycles and the halt line says so
when there are any, which is what makes the difference observable at all -
with the line-clearing removed the total moves by ONE cycle, 20,100 against
20,099, and only the idle half changes, halving to 9,976. A test on
totals could never have seen it. Tests/terminal.sh asks that question,
being the file for things a recorded output cannot see, and fails with the
clear removed while "both reads finished" still passes.

Three collisions, all found by building it:

- 0xFE was the assembler's "not an instruction" sentinel. getOpcode now
  answers a negative NOT_AN_OPCODE, which is outside the range of every
  possible answer instead of inside the unused part of it.
- 0xFE was also what faultTest and faultResumeTest executed to provoke a
  fault. They now use 0xFD and say why, because they did not fail when it
  became an instruction - they HUNG, having started sleeping instead.
- Keys.asm has had a label called "wait" for a year, and mnemonics are
  matched uppercased. What that reported was "Branch without label" at the
  BRQ thirty lines away. The assembler now refuses a label that is already
  an instruction, at the label, by name; every instruction added takes a
  word out of the space of label names, so this will happen again.
2026-08-26 11:11:25 -04:00
AnachronautandClaude Opus 5 e0cf0a9a25 Charge the memory controller for the memory it moves
A blit cost ten cycles, which were the five port writes that set it up. The
quarter of a kilobyte that moved cost nothing, and no hardware moves a quarter
of a kilobyte for nothing.

BANKS ARE SEPARATE MEMORIES, AND THAT IS WHAT SETS THE RATE. A move between two
of them can overlap its read and its write - fetch the next byte while the last
one is stored - so it settles at a byte a cycle. A move within one bank cannot,
and costs two. A fill has nothing to read and costs one whatever the banks are.
The odd cycle on each is the pipeline filling.

That is not a modelling choice so much as a reading of the structure the machine
already has: a Program to Data blit is inherently twice the rate of a Data to
Data one, and it is legible why.

Measured: 256 bytes is 297 cycles across banks and 518 within one, both
including the instructions that ask for it.

WHAT IT TAUGHT, which was not what I expected. Charging for movement costs the
native assembler 0.4 per cent and costs directory work 13.4. The assembler reads
a block and then thinks about it for a long time, so the move is amortised into
nothing; the filesystem reads a block in order to look at it and does nothing
else in between.

So the case for a blitter that runs alongside the CPU is weaker than it sounds.
Concurrency pays when there is other work to do during the transfer, and the
place that spends its time moving memory is exactly the place with nothing else
to do - it blits a block precisely so that it can read it. What that workload
wants is a FASTER controller, not a concurrent one: a wider data path halves the
wait, and the machine is waiting either way.

Video is the case that would still want concurrency, since a frame can be moved
while the next one is worked out. That is an argument about software nobody has
written yet, and it is now an argument with numbers on the other side of it.

The byte at a time port is charged too, for the byte it moves beyond reaching
the port. Nothing polls CTRL_STATUS, so the transfer stalls whoever asked for
it, which is the conservative reading and the one the software already assumes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 21:11:47 -04:00
AnachronautandClaude Opus 5 f1e5cc46f6 A cycle is an access to memory, not an instruction
cycleCount used to tick once per instruction, so RSTA cost what SETD cost and a
CALL moving ten bytes of Stack cost what a branch cost. No machine anybody could
build works that way, and the emulator's job is to be the thing the hardware is
designed against.

Every touch of memory now goes through one of four accessors that charge for it:
fetching an opcode, fetching the bytes after it, reading or writing Data Memory,
and reaching a device port. One access, one cycle, nothing overlapped. The
accessors exist so the cost is counted where the access happens rather than in a
table of per instruction costs kept somewhere else - a table like that is a
second copy of what the code does, and the two drift.

The run loop spends a budget of cycles instead of running a count of
instructions, so the emulated rate means something: an instruction costs what it
touches, and a batch ends when the cycles are gone.

What the numbers say now: RSTA 1 and SETD 4, being one byte and four. LDA 3,
DPUA 2. CALL and RET together 24, RCAL and RRET together 8, because the first
pair moves twenty bytes of Stack and the second moves four. The average SplitBit
instruction costs 3.72 of these, measured over the native assembler assembling a
program.

And the measurement that prompted all of this: converting the filesystem's
hottest leaf routine to RCAL is 3.1 per cent cheaper on a directory heavy
workload. The old model said 0.0, which is what a model that cannot see memory
traffic must say about a change that is nothing else.

Three tests moved. settle() strips the cycle count from recorded output, so
nothing should have churned - but it was anchored to the start of a line and
replCalculator's last output has no newline on it, which leaves the halt message
mid line where the pattern never reached. Not anchored any more.

The two Life programs are bounded by a cycle count because they never end, and
that number was rescaled from 3,000,000 to 11,200,000 - the same amount of work
at 3.72 cycles to the instruction. Nothing about either program changed. No limit
reproduces the old output exactly, because the cut now lands elsewhere in a
frame, so they are recorded again rather than tuned to match.

Whether hardware overlaps a fetch with the end of the previous instruction is
left open on purpose. This is the conservative model; pipelining is a decision to
make while drawing the hardware, not one to inherit from an emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 20:44:38 -04:00
AnachronautandClaude Opus 5 af0360128b Sixty four instructions becomes seventy
The six settled back on the twenty fourth, built now.

RCAL and RRET are a call that puts nothing back. CALL restores A, B and Data
Pointers 0 through 2, which costs ten bytes of Stack and is why a subroutine
here can only hand anything back through Q, DP3 or memory. RCAL costs two and
restores nothing, which is what a short leaf routine wants and is unsafe in
exactly the way the name says.

They are a pair because the frames are different sizes: returning from one
through the other walks the Stack to somewhere that was never a return address.
That was the user's correction to the original proposal, which had a raw call
and no raw return.

DPUA and DPDA offset a Data Pointer by A; DPUW and DPDW by A and B together,
most significant first. DPUP and DPDN take a byte written into the program, so
moving a pointer by something just worked out meant storing it and loading it
back. Down as well as up on symmetry grounds, which was also the user's call -
the argument against it came from counting uses in a corpus written under the
constraint.

The opcodes sit where they belong: 0x16 and 0x1E immediately below CALL and RET,
and 0x4E through 0x51 at the end of the Data Pointer family. All six fit shapes
that already existed, so instructiontable.py needed only set membership and both
machine side copies of the table regenerated from it unchanged.

Checked at every level it exists at: the emulator runs them, the host assembler
encodes them, the monitor disassembles all six with the right lengths, and the
assembler that runs on the machine builds a program using them byte for byte
identically to the host - and that program runs.

The recorded test measures what the two calls COST as well as what they put
back, because an RCAL that quietly did what CALL does would still return to the
right place. It does not survive that: returned through RRET, it hangs.

docs.sh can read a two word number now. The count of instructions taking a Data
Pointer went past twenty, and the pattern only allowed one word, so the check
would have reported that the manual had stopped saying it rather than that the
number was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 17:29:43 -04:00
AnachronautandClaude Opus 5 b6004bdcde Say "boot image" where that is what is meant
"Binary" was doing three jobs. It meant an SPBT file that the machine starts
from; it meant whatever the assembler happened to produce, which is now
either that or a loadable program; and it meant a compiled host tool. A word
that means three things means none of them, and the first of the three has a
name already - this project has been calling them boot images for a while
and the manuals had not caught up.

  Where it means an SPBT file       -> boot image
  Where it means either output      -> output
  Where it means a host executable  -> left alone
  Where it means base two           -> left alone

The user facing messages move with it:

  Error: No boot image specified.
  Usage: ./SplitBit [OPTIONS] <boot image>
  Error: This is not a SplitBit boot image.
  Error: This boot image is in format version 2, and this emulator reads 1.
  Successfully wrote SplitBit boot image to "hello.bin".

The assembler's own help was the interesting case. Its -o writes either
format, so "the binary" there was never right - it is "the output" now, and
the message that names the format is the one that says which it wrote.

No recorded output contained the word, so nothing needed re-blessing.
Checked before starting rather than after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:50:03 -04:00
Anachronaut 08624925fe Interrupt on keypress mode 2026-08-17 16:02:44 -04:00
Anachronaut 91c9d49d1b CosmOS pre-alpha and launchable application versions of old programs. 2026-08-17 15:31:49 -04:00
Anachronaut eff6902bcf Block device peripheral and SBFS file system implemented. 2026-08-16 14:03:29 -04:00
Anachronaut 04dfcd707b Memory controller implemented. 2026-08-15 20:43:53 -04:00
Anachronaut 6d1966d500 Interrupt system implemented, some new programs. 2026-08-15 00:44:13 -04:00
Anachronaut 638b68b25c Long standing assembler bugs fixed, new path system. Make compatibility update. 2026-08-14 16:53:28 -04:00
Anachronaut c2440ae5fa Various bug fixes to assembler, added more data pointers. 2026-08-13 23:41:22 -04:00
Anachronaut e763a3e4de CPU Core Updates.
Overflowing/Underflowing A or B with increment or decrement instructions now sets the Carry Flag.
An increment or decrement of A or B that doesn't result in an overflow/underflow now clears the Carry Flag.
Q is no longer saved and restored in subroutine calls, allowing it to be used to return a one byte result.
Documentation updated with more detail.
2024-11-20 13:57:57 -05:00
Anachronaut d59a91aa38 Removed Duplicate instructions.
LDA and LDB are now Data Operations only. Minor reshuffling of instructions.
Fixed typo in the programming manual.
2024-11-18 13:10:02 -05:00
Anachronaut 2a691ba199 Fixed bugs. 8 bit prime Sieve demo. 2024-11-01 20:53:48 -04:00
Anachronaut c512f07305 Fixed input bugs.
Input from stdin now works as expected in the emulator.
New program to demo input from the command line.
2024-11-01 16:41:25 -04:00
Anachronaut f4c78e68ba DP offset instructions, new demo programs.
Added DPUP and DPDN, which take a one byte operand specifying how far up or down to offset the Data Pointer.
Three Fibonacci generators using the print.asm library.
 - 8 bit values printing in decimal representation.
- 16 bit values printing in hexadecimal representation.
- 32 bit values printing in hexadecimal representation.
2024-11-01 12:13:13 -04:00
Anachronaut 228cf0593a Added stuff. Fixed bug.
Data Pointer value now properly displayed in debug mode.
Added conditional calls to match conditional branches.
2024-10-31 13:30:15 -04:00
Anachronaut c64f097810 Added bit shift instructions.
SHL and SHR treat A and B as a 16 bit circular shift register and rotates them either left or right.
2024-10-30 16:06:33 -04:00
Anachronaut a58fb3d694 Added new instructions.
Added CCF - Clear Carry Flag
Added BRC - Branch on Carry Flag
Improved CALL and RET - All registers but status now saved and restored.
2024-10-29 21:04:03 -04:00
Anachronaut 992e44ca12 CALL and RET instructions.
Changed PSHP and POPP into CALL and RET, streamlining their use for subroutine calls.
Updated Assembler and Emulator.
New demonstration programs.
2024-10-27 19:18:54 -04:00
Anachronaut dfa5ad2638 Assembler Completed
Added the Assembler.
Added instructions for the assembler to README.md
Added Assembler Manual
Modified Makefile to build the Assembler.
2024-10-26 15:31:24 -04:00