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.
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.
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.
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
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.