The console says WHICH KEY WENT DOWN, which 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.
Lunar Porter's thrust is a burn per press for exactly that reason.
So a pad is its own device on ports 0x60 to 0x6F, reporting a LEVEL. One
read gives every button at once, holding is the natural thing to express,
two directions together cost nothing, and reading does not consume it - a
game may ask twice in a frame and be told the same thing both times.
Four of them, because a party is four. They cost a port each and nothing
at all when unused. The directions are the low nibble so "which way" is an
AND with 0x0F; the buttons are the high nibble for the same reason. 0x64
says which are really there, so a game can ask for a controller rather
than sitting silent while somebody presses things at it. They never
interrupt: a game polls once a frame because that is when it draws.
KEY-UP ON THE CONSOLE WAS THE OTHER WAY TO DO THIS AND 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.
Voyager drives pad nought from the keyboard as well as from any real
controller, OR-ed rather than chosen between, so a game written for a pad
is playable on a machine with none and unplugging one mid-game does not
leave somebody holding nothing.
And a recorded path, which is what makes any of it testable: --pad names a
file of one byte a frame, and the manifest has an eighth column for it.
A BYTE A FRAME AND NOT A BYTE A READ - a level asked twice in one frame
has to answer the same both times, and a file that advanced per read would
depend on how the program happened to be written. Voyager's own tests run
headless with nobody holding anything, so without this the device would be
exercised only by somebody playing: the state the console's line editing
was in when it broke twice in two days.
0x50 is the timer, not free. The block this went in was chosen after
looking rather than before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
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
The only regular thing on this machine was the screen finishing a frame,
sixty times a second and not negotiable - a clock a program BORROWS rather
than one it sets. Every duration became a multiple of 16,667 cycles, so a
sixteenth note at 120 beats a minute, which is 125,000, is seven and a half
frames and cannot be asked for at all. The way round it was to choose a
tempo whose subdivisions happen to land on whole frames, which is making
the music fit the machine. Examples/tune.asm says so in its own header.
0x50 Status: a period went by, it is running, it will interrupt
0x51 Control: run, repeat, interrupt
0x52-0x54 The period, in cycles, most significant first
THE PERIOD IS IN CYCLES because that is what everything else here is
counted in - the cost model counts them and a frame is measured in them -
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, with
120 beats a minute at 500,000 in the middle, and there is no range left for
a prescaler to buy.
Starting loads the period; asking it to run while it already is does not,
so turning interrupts on half way through a period does not silently move
the beat being kept. What is left over carries into the next period, so a
period of 1,000 ticks every 1,000 and not every 1,000 plus however late
anybody looked. Reading the status takes the tick down and the line with
it, which is the rule this machine settled two days ago about every status
port.
The timing check is in terminal.sh and not the manifest, and the reason is
worth keeping: settle() strips cycle counts from recordings, which is right
for every other program and useless for a clock. "It printed eight dots"
would pass on a timer that fired them all at once. terminal.sh measures
that eight periods of 125,000 come to a million within a couple of hundred
cycles, and that 99.97% of them were spent asleep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
SEVERAL DISKS ARE ONE CONTROLLER AND NOT SEVERAL DEVICES, and the
instruction set decided that rather than taste. A port is an immediate byte
inside the OUT that names it - portOut takes it from Program Memory - so a
program cannot compute one. "The disk on port 0x20 plus drive times four"
is not something this machine can say, and two disks as two devices would
mean a branch on the drive number in all eleven places sbfs.asm names a
disk port. A drive register is what a floppy controller has always been.
0x24 Drive, which the block, command and status registers refer to
0x25 Drives, read only: how many are plugged in
--disk given more than once fills them in order. What is per drive is the
image, its size and its write protection; the block register, the status
and the one buffer belong to the controller, which is the same division
real hardware makes.
A drive that is not there is refused rather than wrapped, because wrapping
means a program asking for a drive this machine has not got quietly reading
the one it has - the same shape of fault as taking a bank number somebody
else was using. An EMPTY drive is a different thing and is selectable: a
controller has its drives whether or not there are disks in them, and
reading one fails with the error bit the way an empty drive should.
Changing drives finishes whatever the one 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 would be a fault with no
owner.
Also stops parseOptions setting its defaults field by field. It was nine
assignments beside a struct, and a list beside a thing drifts from the
thing: adding two fields left them holding whatever was on the stack, so a
machine given one disk was told it already had four drives. It is one
zeroing now, and a default that is not nought can be written under it where
it reads as the exception. That struct growing a field once before left
Voyager linked against an object that disagreed about its size.
Nothing in CosmOS uses any of this yet. The mount record is next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
A device raises a line and something has to take it down. Two things did:
being interrupted, and being woken from WAIT with the Interrupt Flag down -
the second because a masked program has nowhere to dispatch to, so nobody
else would.
There was a third way to learn a device had finished and nothing answered
it. The documented idiom reads the status, branches out if the device is
already done, and only WAITs otherwise; on a disk quick enough to finish
before the first look, which is every disk here, the WAIT is unreachable.
The line then stood for the rest of the machine's life.
The program that leaves it standing never pays for it - it was masked
throughout. The bill arrives at whoever next sets the Interrupt Flag. The
boot chain reads the disk to load a program, leaves the line up, and hands
over; the loaded program is then interrupted on behalf of a read that
finished before it existed, through a vector table with no entry for a
device it never touched, and faults on the instruction after its SIF.
Found by running Examples/tune.asm through Once. It set up its whole sound
and died four bytes before its first note, which is why it was silent
rather than wrong - and why it looked like a sound bug for a while.
So reading the port that answers a device takes its line down, the same way
taking the byte already took the console's down. Disk and screen do it on
their status port. And a reset now clears every line, which is the sentence
the manual already makes about the vector table: a handler left behind aims
an interrupt into a program that is no longer running, and so does a line.
testPrograms/diskLineTest.asm pins it - the racy idiom, then SIF with no
handler installed anywhere. It faults without the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
A list of files in a makefile goes stale the moment somebody adds a program and forgets to
name it, and what they forgot is invisible until they go looking for it on the machine. So
SplitDisk gained a mirror command and the disk rule is one line: putting a file where the
others live is now the whole of putting it on the disk.
EVERY FILE GOES THROUGH put AND EVERY DIRECTORY THROUGH mkdir. That is the point of it -
mirror adds a walk and no filesystem code at all, so anything the format refuses here it
refuses everywhere, in the same words. What is new is the walk, and the walk is what the
six checks in Tests/disk.sh are about: that it goes all the way down, that it leaves dotfiles
and named directories behind, and that a name too long stops it.
REFUSED RATHER THAN SKIPPED, because a disk quietly missing a file is the exact failure a
mirror exists to prevent. Which meant four sources had to be renamed - a directory entry
holds 22 characters and they were 23, 23, 24 and 29:
16bitSegmentedSieve.asm -> 16bitSieve.asm
16bitSegmentedSieveModern.asm -> 16bitSieveModern.asm
consoleInterruptTest.asm -> consoleInterrupt.asm
controllerWriteTest.asm -> controllerWrite.asm
The test names in the manifest are unchanged, since those are identifiers and every recorded
result is filed under them. Only where the source lives has moved.
The entries are sorted before anything is written. readdir hands them back in whatever order
the host filesystem feels like, and a disk image that comes out different from one run to the
next is an image no test could compare against another.
The disk grew from one megabyte to four and from 192 directory entries to 1,024. The sources
are 2,850 blocks and the mirror filled the old directory on its first run, which is a thing
that should not need thinking about again.
The Tests fixture disk is deliberately NOT mirrored. It is a controlled fixture with known
contents, and the shipped disk is the one meant to be useful; they want different things.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
The console had grown an ANSI parser, and that was the wrong shape. 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, so reaching it by sending
characters for a state machine to take apart is a middleman for something the machine does
better - and it meant accepting an open protocol somebody else defines, in hardware, with no
natural end to it. Everything else on this machine is registers.
So the console gets three: cursor row at 0x03, cursor column at 0x04, and a command port at
0x05 where 1 clears the screen. Both cursor registers are READ as well as written, which is
the thing an escape cannot do without sending a query and parsing a reply - a routine that
wants to put the cursor back where it found it can now ask.
Clearing is one command against a thousand cells walked one at a time. Snake and Life are
smaller for it: 2,168 bytes to 2,163 and 1,410 to 1,396.
A HOST TERMINAL STILL SPEAKS ANSI, and bridging to the host is the emulator's job, the same
job it does reading standard input. So the escapes are now GENERATED, outbound, for the set
this device chooses, rather than parsed inbound as though the machine were a terminal. The
set cannot grow behind our backs because we are the ones saying it. The cursor is announced
lazily, at the next character rather than at the register write, so setting a row and a
column costs one sequence rather than two.
The console's block widens from three ports to six, which registryTest noticed: it had been
asking about port 0x05 precisely BECAUSE nothing was there, and the console had just moved
in. Re-blessing it would have left it checking nothing, so it asks about 0x80 instead -
clear of the console, the disk, the screen, the controller, and the sound device coming to
0x40.
Six checks in Tests/video.sh swapped from the sequences to the registers, including that the
cursor reads back and that one sent past the edge is clamped rather than refusing. Those
checks also stopped counting bytes from the ends of a file, which had quietly started
measuring an escape the moment the console began announcing the cursor.
SplitLint caught the one thing worth catching in the port: the clear command leaves A at 1
and key mode is also 1, so the second load looks redundant. Acting on it would tie a console
command to a console mode by coincidence, and break silently if either ever moved, so it is
suppressed with that reason rather than removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
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.
Twenty four more sites, and the interesting part is which ones were left
alone. A rule emerged while reading them and it held all the way through:
apply where the repetition is INSIDE one operation, skip where the author's
own structure says it is a new thought, and never where two equal values
mean different things.
Taken:
- Five registers reassigned to a value they already held, where both are
the same quantity: two masks in one expression in Snake, two spaces
printed by the monitor, both halves of block zero in waitTest, and a RSTA
in Pour that the very next instruction overwrote.
- Eighteen SETDs that reload a pointer inside one operation - a store back
into the variable just read, or an INCD stepping to the second byte of a
two byte value. Those read correctly without the reload.
- sbfsNext, which branched to the label on the line below it.
Left, with reasons that are the useful part of this:
- Eight registers where the same number means two different things. CosmOS
and the loader set A to 1 for a blit command and then to 1 again for a
bank number; Asm compares a type against 3 and then a status against 3.
Removing those couples one quantity to another that is equal by accident
and would part company silently.
- Ten RSTAs that open the RSTA/RSTB/CCF/ADD "return zero" block. The
redundancy is what makes that idiom self contained; taking it out makes
the return value depend on the line above.
- Eleven SETDs that begin an arm of a comparison chain. Each arm loads,
compares and branches, and they get reordered - the repetition is the
reason a new arm can be dropped in anywhere.
- Twenty five SETDs separated from their pointer by a blank line or a
comment, which is the author saying a new thought starts here.
- Two CCFs before arithmetic, which this codebase writes unconditionally.
- Three redundant branches in test programs whose recorded output includes
addresses, where three fewer bytes moves what the test demonstrates.
Nine recorded outputs moved and every one is a size in a listing or, for
Life, five more generations inside the same cycle budget. Behaviour is
unchanged everywhere: cosmosSnake and cosmosEdit pass byte for byte while
Snake loses eight bytes and Edit twelve.
CosmOS is 10,902 bytes of program against 10,937, and the native assembler
12,173 against 12,183. The CosmOS README's size for Edit moved twice in one
sitting, and this morning's check caught it both times - which it could not
have done before that claim was reworded to name what it was about.
Twenty four places moved Q into A or B by pushing it and popping it back.
That is four bus cycles and two bytes to do what MVQA does in one of each,
and several of them are inside loops - Life, the calculator, int8. Nineteen
more loaded zero with INIA 0d0 where RSTA says the same thing in one byte.
Both are equivalent at the CPU rather than by assertion: RSTA and INIA both
leave Status alone, and PSHQ followed by POPA nets to A = Q with the Stack
Pointer where it started. The one difference is that the pair leaves a copy
of Q in memory just below the Stack Pointer and MVQA does not, which
nothing here reads.
Five recorded outputs moved and every one of them says the change worked:
- 16x16Life fits five more generations into the same cycle budget, the
first 457 lines identical, because the loop got cheaper.
- Life.sbx is 1409 bytes rather than 1411, in three tests that list it.
- Edit.sbx is 1995 rather than 1996.
That last one broke a check I added this morning, and the hole is worth
recording: the CosmOS README's claim about Edit's size did not have the
word "Edit" on the same line as the number, because the subject was in the
sentence before, so the check that measures quoted sizes skipped it
silently. The sentence now names what it is talking about, which makes it
both checkable and clearer, and the check fails on a wrong number there.
Comments on either half of a replaced pair are carried onto the
instruction that replaces them, so nothing anybody wrote was lost.
SplitLint reports valid assembly that has a shorter direct expression:
zero loads that could be RSTA or RSTB, Q moved through the stack where MVQA
would do, self-cancelling push and pop pairs, assignments overwritten
before use, unreachable fallthrough, one-byte pointer moves that could be
INCD or DECD, a branch to the label directly below it, a SETD reloading an
address the pointer already holds, and branches whose carry is known.
Its model is deliberately local and conservative: every label and every
directive forgets all known state, so a claim only ever lives inside a
straight-line region. It knows the calling convention - CALL forgets DP3
and keeps the rest, RCAL and SWI forget everything - and it shares
assembly.o with the assembler, so an added opcode cannot leave it holding a
private copy of the instruction table.
260 warnings across the corpus, of which three were wrong in the way that
matters: branchTest.asm and interruptFlagTest.asm exist to check that a
branch whose carry is known behaves correctly, so a diagnostic saying the
outcome is known is exactly right and exactly unwanted.
A line whose comment says "splitlint: <reason>" is now not reported on. THE
REASON IS REQUIRED and a bare marker is refused, because a suppression
nobody explained outlives whatever made it necessary. Suppressed warnings
are not counted, so --fatal-warnings does not fail on one, and the number
of them is printed at the end so the claim is visible rather than silent.
Tests/lint.sh checked a TOTAL: twenty three warnings expected, twenty three
found. That number stays right while the thing behind it goes wrong - a
rule that stopped firing while another fired twice would pass, and so would
a rule reporting at the wrong line. It now checks which warning came out
and at which line, that nothing else came out, and that the four lines
meant to stay quiet did. Confirmed by breaking one rule's message and
watching it name that rule: the old assertion passed the same sabotage,
because the warning still fired and the count never moved.
Written with the user while I was away; my part is the suppression
mechanism, the harness rewrite, and the three marks in the test programs.
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
DPUP takes an immediate, so an offset of one is legal and does exactly the
right thing. It is also three bytes where INCD is two, and reads as "offset
the pointer up by one" where INCD reads as "step the pointer".
56 of them across 15 files: the system, the assembler, the editor, and eight
test programs. CosmOS is 9,564 bytes to 9,537, the native assembler 11,648
to 11,635, and every program in the repository together 49 bytes lighter.
The worst offender was numbers.asm, written this week, where every sixteen
bit helper reaches the low byte and comes back the long way round. It is the
file every other part of the assembler includes, so it is the first thing
anybody reads when they go looking - and it was teaching them the long way.
Pattern matched off sbfs.asm rather than off the instruction table I had
just embedded in two programs.
THIS IS NOT TWO WAYS TO DO ONE THING. DPUP takes an arbitrary number, so one
is inevitably among them; INCD earns its place by making the common case a
byte cheaper. The overlap is structural and the choice is a usage question,
which is a linter's job rather than an ISA's - "DPUP.n 0d01: INCD.n does
this in a byte less" is a mechanical rule with no judgement in it.
Nothing needed re-recording, which was not a foregone conclusion: cosmosBreak
prints the system addresses the registers happened to hold, and they did not
move. Both assemblers still produce identical bytes and CosmOS still builds
itself to a fixed point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
The quotes are gone by the time a token is classified, so checkIfKeyword's
test of token[0] == '#' matched the STRING "#Program" sitting in a program's
Data Segment. It was read as the directive: the segment silently changed in
the middle of the data, the string's nine bytes were charged to the Program
cursor instead of the Data one, and every label defined after it came out
nine bytes wrong - in a file that still had a valid header, a plausible
length, and nothing to say about any of it. The only symptom was a program
that jumped into the middle of an instruction.
This is the FOURTH of the family. A string spelling a mnemonic assembled as
that instruction; a string beginning with a zero was rejected as a malformed
literal; a string in the Program Segment was discarded in silence. The
instruction check and the literal check both carry a "not a STRING" guard
already. This one did not, so it has one now, and it lives inside
checkIfKeyword rather than at the call site so it cannot be left off again.
Nothing had ever triggered it, because nothing had ever needed a directive's
name as data. An assembler written FOR this machine necessarily does: it has
to compare tokens against "#Program" and "#Data". It was found by building
one and watching it fault on its second instruction.
Test stringKeyword puts every directive name in a program's data and prints
a label defined after them. Verified that it bites: without the fix the
assembler refuses the file outright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
A token is classified after its quotes have been stripped, so a string
literal reading "ADD" looked exactly like the ADD instruction and was
assembled as one. It failed with "attempting to assemble outside the
Program Segment", a message about a mistake nobody had made.
The literal and label checks were already guarded against strings and the
instruction check was not. Mnemonics match without regard to case, so
"or" and "and" were caught by this too, and those are ordinary enough
words to want in a message.
Third of its family, after a string beginning with '0' being read as a
malformed number and a string in the Program Segment being silently
discarded. All three have the same root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>