Commit Graph
65 Commits
Author SHA1 Message Date
AnachronautandClaude Opus 5 e4f4bae762 Work across two disks: copy between them, and run a program from one on
files from the other

Two things anybody expects of a second disk, and each needed something
different.

COPYING NEEDED TWO THINGS TO REMEMBER A DRIVE.

The write stream is the only thing here that lives across service calls, so
it is the only thing whose drive can change underneath it: every
osFileBlock names its source path again and goes back to the source drive,
and then osFileWrite has to come home. It records the drive it was opened
on and returns there.

And the file lookup CACHE. It keeps the last path resolved so a reader
walking a file does not re-walk the directory for every block - and
skipping the walk skipped the drive the path named, so block one of a
cross-drive copy read the source's block numbers off the DESTINATION disk.
It only showed on files of more than one block, because a file of one is
never looked up twice. One block worked and two did not, which is a
suspicious enough shape to have suspected sooner.

RUNNING A PROGRAM FROM ELSEWHERE NEEDED A THIRD PLACE TO LOOK, and two
restorations.

The shell tried where you are and /Apps on the disk you are on. It now
tries /Apps on drive 0 as well, which is what makes the system's programs
work from a disk of your own - one with your files on it and no system,
which is most of the point of having a second disk.

The drive goes back after the load, because by then the program is in
memory and the blocks it came from mean nothing; and again when it exits,
because a program that copies between disks moves the drive as its own
paths need to and being left wherever it finished is not what was asked
for. Copy 1:/a 0:/b now leaves you exactly where you were.

The fixture disk grew an /Apps, because it kept its programs at the root
and so could not exercise the third place at all.

Two hours of the debugging above were spent on a stale disk image. The
machine boots the system that is ON the image, so a rebuilt cosmos.bin
means nothing until the image is rebuilt too - and the trace said my new
code never ran, which was true. Third time this project has been misled by
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 16:14:53 -04:00
AnachronautandClaude Opus 5 4cc6393f5b Name a drive in a path
"1:/notes", or "1:" on its own for wherever that drive already was. Done in
sbfsWalk, which is where every path in the system arrives - eight callers
between the shell, the config reader and the filesystem - so it works for
anything that takes a path rather than for whichever commands somebody
remembered to change.

NAMING A DRIVE GOES THERE AND STAYS THERE. Switching for the length of one
command and switching back reads better and cannot work: a path resolves to
a start block and a length, and those mean nothing without the drive they
were read from. A load that resolved on drive 1 and then read its blocks on
drive 0 would read the right blocks of the wrong disk.

A name beginning with a digit is still a name. The colon is the whole of
what tells them apart, and /2things is on the fixture disk to keep it that
way.

Two bugs, and the second is the interesting one.

SUB sets carry on a BORROW, so a character below '0' leaves it set - and
the test for "not a digit" branched on clear. Every prefix was ignored.

Then the leading-separator test reads the first character through DP0,
which sbfsPathDrive could not move because RET puts DP0 back the way it
found it. It advanced SbfsPathAt and DP0 still pointed at the digit, so
every prefixed path was judged relative and walked from the named drive's
working directory. IT ONLY SHOWED WHEN THAT DRIVE WAS STANDING SOMEWHERE
OTHER THAN ITS ROOT, because a relative walk from the root is an absolute
one - so "cd 1:/2things" worked from a fresh boot and failed after "cd
1:/notes". The test does it in that order for that reason.

Copying between two disks is still not one command: each path resolves on
its own drive and the drive stays where the last path left it. That wants
Copy to change drives between blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 15:35:11 -04:00
AnachronautandClaude Opus 5 5644c24113 CosmOS knows about all four drives
A mounted disk is EIGHT BYTES - where its directory starts, how many
blocks it is, how big the disk is, and where you are on it. They now sit
together in the data segment, and changing drives is one copy out and one
copy in. The other three thousand lines of filesystem go on reading the
same four names they always have and never learn there is more than one
disk, which is the whole reason this was affordable.

The version is not in the record. It is checked at mount and thrown away,
because a version one disk's zero parent already reads as "in the root".

Every drive is mounted at boot: the controller says how many are plugged
in and each is tried in turn. One with nothing in it, or a disk this
cannot read, is left unmounted rather than stopping the others, so a
machine with a good disk in drive 0 and a blank in drive 1 starts.

'drive' says which one, 'drive 1' goes to another, and the working
directory goes with it - where you are on a disk is part of which disk you
are on. A drive the machine has not got is refused, and refused
differently from one that is there with nothing readable in it.

Three things the assembly caught me on, all the same misunderstanding of
what survives a call:

  - OR reads A and B, and the bit came back from sbfsDriveBit in Q, which
    RET does not disturb - but RET does put A back. The mounted mask never
    got set and drive 0 was reported unmountable.
  - MVQA then RSTA throws away the copy it just made, so doubling a bit
    doubled nothing. SHL does it in one instruction, because A and B are
    one register to it.
  - There is no move from A to B. INB reads a port straight into B, which
    is what the drive count comparison wanted.

run.sh takes more than one image now, separated by a plus, since the
machine has four drives and a test that could only name one could not
check any of this.

The buffer note is forgotten on a drive change and that is DELIBERATELY
kept although nothing can currently reach it: only the file read-ahead
consults it, a directory scan does not, and finding a file requires a
scan which overwrites the note on the way past. Two disks were built with
the same file at the same block to try to catch it and the answer was
right either way. Three instructions to hold an invariant rather than a
story about a bug - and the comment says so instead of claiming a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 12:30:09 -04:00
AnachronautandClaude Opus 5 b1538e0618 Give the disk four drives, behind one controller
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
2026-08-31 10:10:38 -04:00
AnachronautandClaude Opus 5 3b650cabcd Grid took the disk's bank number, and gave the screen back untidy
Found by playing with it: after running Grid, the shell could not start
anything by name and dir said the disk was empty. Several commands after
the program that did it had exited, and nothing had said a word.

BANK NUMBERS ARE ONE NAMESPACE FOR THE WHOLE MACHINE. Grid registered video
memory as bank 3, which is the number CosmOS gives the disk's buffer when
it mounts - and that does not fail, it succeeds. Every read the filesystem
made afterwards came out of video memory. Grid uses 4 now, and the CosmOS
README has a table of who owns what, because the one place this was written
down was a line in a service description about sbfsMount.

Nothing hands bank numbers out and nothing refuses one that is taken. If
programs start wanting banks routinely, a service that allocates them is
what should exist rather than a longer table - noted there rather than
built, since one program wanting one bank is not yet a system.

Also puts the cursor home on the way out. The map was emptied and the
console was not told, so the shell carried on writing from wherever the
cursor had been standing when Grid started - twelve rows down a screen with
nothing on it. Clearing is what homes a cursor and it costs one write.

The regression test runs a program by name, then Grid, then the same
program again; the second one is the check. Putting Grid back on bank 3
fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 21:14:07 -04:00
AnachronautandClaude Opus 5 553882d28d Start CosmOS with a script, and let a script hold its tongue
Three things scripts wanted, and they are one thing: a machine that can
have a face.

/System/Boot/startup.sh runs before anybody can type. Every way of reaching
the prompt for the first time goes through it, including the one where
there is no disk - in which case there is nothing to find and nothing is
said. A MISSING one is ordinary and silent, because a clean install has
none and a machine that complained every boot about a file nobody wrote
would be teaching its owner to ignore it. One that is THERE and does not
begin with #! is the other case entirely: somebody meant that to run.

#quiet stops each line being echoed, #loud puts it back. The prompt and the
echo go together, because together they are what makes a script look like
typing, so a quiet script gets neither and what it prints is all that
appears. A nested script inherits quiet - a build that asked for it meant
its helpers too - and gets its own setting back when the helper returns.
Anything else beginning with # is handed to the shell, which does not know
it and stops the script, because a script that asked for something this
shell cannot do should not carry on as though it had been given it.

clear empties the screen, which the console has been able to do since
before there was a screen to do it on.

THE PROMPT IS NOW SAID BY WHOEVER SUPPLIES THE LINE. It used to be said at
the top of the loop, which is a decision made before the line is read and
an answer not known until after - and it was wrong at both ends. #quiet is
itself a line, so its prompt went out before anything knew to stay silent;
and the line after a quiet script's last one comes from the console, having
already been denied one. Off by exactly one line in opposite directions. A
first attempt at this remembered whether the prompt had been skipped, which
worked and was a flag standing in for a structure. The monitor's assembler
prints a prompt of its own, so it reads through shellReadRaw, which is the
same source without one.

One admission. Handing the console its prompt back when a quiet script
ended was a real fix when I wrote it and stopped being one an hour later,
because the restructure above means the console's own path prompts whatever
the flag holds. The comment claimed it fixed something. Breaking it on
purpose changed nothing, which is how that was found, and it is now a
comment saying so instead of a line pretending to work.

The startup fixture ends QUIET on purpose: nothing puts the flag back when
the outermost script finishes, so a script ending #loud would have tested
the easy half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 16:59:23 -04:00
AnachronautandClaude Opus 5 c28826df77 Let a script run a script, four deep
A build script calling a setup script is the first thing anybody tries.

What is saved when one script starts another is A POSITION AND NOT A
BUFFER: the name, which block comes next, how many are left, and where in
the block it had got to. Seventy bytes, and they sit next to each other in
the data segment on purpose so that saving them is one copy. The block
itself is read again on the way back, which costs one disk read per return
and saves 257 bytes a level - the inner script reads its own block into the
single buffer there is, so coming back means fetching the outer one's block
again and landing on the byte it left.

The slot is reached by stepping rather than by multiplying, because this
machine has no multiply and the depth is never more than three steps.

Four levels. Deep enough for a script calling a script that calls a helper,
shallow enough that a script running itself says so rather than filling
memory. A line that fails now stops every level and not just the innermost,
because a build whose helper failed should not carry on in its caller.

The caller's place is saved BEFORE the new file is looked at, and put back
on every way out that is not success. Opening writes the name into the live
state in order to ask the disk about it, so by the time "there is no such
file" is known, the caller's place has already been overwritten - a failed
'do' inside a script would otherwise leave the script that ran it reading
from a name it never chose.

The test resumes in the outer script's SECOND block, which is the case the
whole design turns on and the one an ordinary nesting test would miss.
Breaking the re-read, the save, or the limit each fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 16:22:09 -04:00
AnachronautandClaude Opus 5 2466d79d9c Let the shell run a file of commands
'do <file>' runs the lines in a file as though they had been typed. The
only thing a script changes is where the next line comes from: everything
below shellReadLine - splitting the line, matching it, loading a program -
cannot tell the difference and does not have to.

What makes a file a script is '#!' on the front of it, not its name and not
a flag in its entry. The rule this filesystem keeps is that an entry holds
only what the content cannot say about itself, and a script can say what it
is; the loader already refuses anything that is not SBEX, so the two kinds
of runnable file turn each other away without either knowing about the
other. It is also the deferred half of the file-typing design, which said
to wait for a second kind of runnable thing before building any of it. This
is that second kind.

'#' is a directive and ';' is a comment, as in SplitBit assembly - one rule
across the machine rather than two dialects. Not Unix's convention: there
'#!' really is a comment that only the kernel reads, while here the shell
requires it and refuses the file without it, so calling it a comment would
be a lie about what it does.

A script stops at the first line that does not work, which is what the
LineFailed groundwork was for. Comments and blank lines are dropped by the
reader rather than by the dispatch, so they are not echoed either. A script
running out hands back to the console rather than ending the shell, because
running out of file and running out of typing are not the same thing. The
interactive assembler reads through the same path, so a script can contain
a block of assembly.

Three things this cost that were not obvious:

  - RET puts A and B back, so a routine cannot answer in them. scriptByte
    returning the character in A assembled, ran, and handed the caller its
    own A back every time. It answers in memory now.
  - A last line with no newline is still a line. Text files do not reliably
    end with one and an editor eating it is a bad way to find out a command
    did not run.
  - Not LastStatus. See the commit before this one.

Six checks in three tests, two of which are about byte positions rather
than behaviour - a command lying across the boundary between two blocks,
and that missing newline - so their fixtures are generated rather than
committed, where an editor cannot helpfully repair them.

Nesting is not in yet: a script cannot run a script. That wants a stack of
positions rather than the one the reader keeps.

Also derives native.sh's self-hosting source list from cosmos.asm's own
#Include lines. It was a hand written list and went stale the moment
script.asm existed - the fourth time a list beside a thing has drifted from
the thing - so it now asks the thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 15:50:26 -04:00
AnachronautandClaude Opus 5 2f22807458 Give the demo a real tune: the Anachronaut Labs theme
The composition is the user's - a leitmotif they already have variants of,
written into the note table by hand. It replaces the eight note scale that
was there to prove a note could be played at all, and it is a better demo
for the obvious reason and one less obvious one: it is long enough to hear
whether the machine keeps time, which a run of eight notes is not.

Forty five notes, 567 frames, 9.45 seconds, peaking at 19,461 of 32,767.
The test budget goes to twelve million cycles, which is a duration rather
than a guess: at 16,667 cycles a frame the music is 9.45 million, so there
is room to add bars before anybody has to come back here.

Also records what borrowing the screen's frame actually costs, which came
out of writing music rather than out of theory. The frame is not too slow,
it is FIXED: every duration is a whole number of 16.67 ms, so a note worth
a third of a beat cannot be written, and the way round it is choosing a
tempo whose subdivisions land on whole frames - making the tune fit the
machine. That is the argument for the timer peripheral wanting an arbitrary
tick rather than a faster fixed one, and for the screen not being the clock:
a display refresh and a music routine have no reason to share a rate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 22:33:33 -04:00
AnachronautandClaude Opus 5 85329f13c3 Take a device's line down when its status port is read
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
2026-08-29 22:15:02 -04:00
AnachronautandClaude Opus 5 d388cd3122 Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.

Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.

Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.

Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:

  - Only one voice had an oscillator switched on, so three of the four
    channels could not make a sound whatever was written to them.
  - That voice's oscillator arrived at full gain and every other one
    arrived at nothing, an asymmetry with no reason behind it.
  - A note with no sustain is silent but not over, so the obvious way to
    wait for a sound to finish waits for ever.

The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.

Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.

Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:17 -04:00
AnachronautandClaude Opus 5 13b20c8834 Give the screen a bitmap mode
V4. Mode 2 is 320 by 200 with a byte a pixel: no tile to look up and no attribute to add,
the byte IS the palette index. Programs/Examples/picture.asm fills a whole one in 127 bytes
of program and 47,498 cycles.

IT IS THE SAME MEMORY AS THE TILES AND THE MAP, which is what shared video memory has always
been, and there is nowhere else it could be - 64,000 bytes of picture in a 65,536 byte bank
leaves room for nothing beside it. Going to bitmap mode does not clear the text screen, it
stops calling it one, and coming back finds the tiles holding whatever the picture put there.
Taking the screen means taking it.

The palette moves to 0xFC00, the top of video memory, because it is the one thing that has to
mean the same in every mode and 64,000 bytes of picture leaves nowhere in the middle for it
to hide. That is a documented address, so the example, the tests and the manual move with it.

A BITMAP HAS NO COLUMNS AND NO ROWS, and both registers read zero rather than a leftover from
the last mode. The console asks: told there is no character screen, it has nowhere to put a
glyph and draws nothing, while still saying everything down the serial line. The honest
alternative is what a machine with shared video memory really does, which is scribble marks
nobody can read across somebody's picture - honest and useless, since a program that has
taken the screen has not stopped wanting to print.

Six checks in Tests/video.sh, to 55: that the mode is 320 by 200, that a byte is one pixel's
colour and only that pixel, that printing leaves a picture alone while the letter still goes
out, and that the columns register says nought and then forty again.

The example is worth reading for one thing beyond the mode: Fill leaves its destination past
what it touched, so two hundred rows are drawn from one address set once. Working out where
row n begins would be n times 320, and this machine has no multiply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 10:44:15 -04:00
AnachronautandClaude Opus 5 1174bd9af5 Give the machine a frame to wait for
V3. The screen interrupts at each frame on hardware vector 0x30, and WAIT finally has
something worth sleeping on.

THERE WAS NO CLOCK. Every program that wanted to happen at a certain speed counted
instructions and hoped, which is why Snake's pause silently halved the day a cycle stopped
being an instruction and became a memory access - the program was right and the thing it was
counting changed underneath it. A screen finishing sixty times a second is a real beat, and
it is counted in the MACHINE'S cycles rather than the host's, so the same program sees the
same number of frames in the same number of cycles however fast anything really ran. That is
what makes a frame something a test can count and a recorded result can hold.

Status bit 0 goes up when a frame has gone by and reading the status port puts it down, so a
program with no handler can watch for it instead. Control bit 0 asks to be interrupted, and
is OFF when the machine starts: 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.

More than one frame can pass between two looks, and the flag and the line are each one
thing, so several still mean one of each. A missed frame is missed.

Programs/Examples/frames.asm prints a dot a frame for a second: 1,000,324 cycles, and 996,460
of them spent asleep. That split is the thing worth seeing - a program that polled instead
would print the same sixty dots, take the same second, and spend every cycle of it on the
bus. Its header explains why waiting is not spinning and why a machine with a beat can stop
guessing at one.

Six checks in Tests/video.sh, and two of them are about the clock rather than the output,
because the output cannot tell the difference. That the machine slept through nearly all of
ten frames, and that polling three frames actually took three frames - a status flag that
stayed up once set would print exactly the same character and look perfectly correct.

Breaking the frame interrupt on purpose left a machine asleep for ever and hung the whole
suite, which is a worse way to be told than a failing check. Tests/video.sh bounds its runs
at ten seconds now, the way Tests/run.sh always has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 10:24:09 -04:00
AnachronautandClaude Opus 5 0852666e73 Mirror the source tree onto the system disk
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
2026-08-29 08:45:46 -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
AnachronautandClaude Opus 5 978aec4809 Let the console edit a line, and let a file be a keyboard
BACKSPACE REACHED THE SHELL. A terminal in line mode does not hand a program every
keystroke: it collects a line, rubs out a backspace, and delivers the finished thing at
Return. CosmOS has always relied on that, and behind a window there is no terminal to do it,
so the raw 0x08 went into the command buffer. Correcting a typo produced a line that looked
perfectly right on the screen and matched no command at all - "I do not know: help".

So the console does it, because behind a window the console IS the terminal. In key mode it
does not, and must not: a program in key mode asked for every keystroke as it happens.

CosmOS now asks for eighty columns at boot. Its own help text is seventy-four characters
wide, and dir, the monitor and the assembler's messages all assume room. The machine still
wakes up in the smaller mode, which is right for a machine - it is the system that knows
what shape of screen its own output needs, and a game that wants forty columns says so.

AND A FILE CAN BE A KEYBOARD, which is the part that matters beyond today. The console
behind a window is not the console behind a terminal, and until now the difference was
unreachable: it broke twice in two days and a person typing found it both times. --keyboard
installs the same hook a window does, so the same path runs, and the manifest has a column
for it. cosmosTyped types "halp", backs over it, arrives at "help", and requires the help to
come out. Verified by removing the rub-out, which loses the whole help text.

It does not test the window. Voyager's key queue and everything about presenting frames are
still out of reach. It tests the console, which is where the logic is.

Along the way: VOY_OBJS was missing from the dependency include, so voyager.o never rebuilt
when a header changed. EmulatorOptions grew a field, Voyager kept an object that disagreed
about the size of the struct, and smashed its stack on every run. A clean build hides it and
'make sanitize' cleans first, so that would never have found it either. Tests/voyager.sh did,
by failing all 115 tests that start the machine - which is the differential test earning its
keep on a bug that has nothing to do with what it was built to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:39:35 -04:00
AnachronautandClaude Opus 5 8fbbeb6ec9 Make xfail compare the diagnostic, not just the failure
The Test Manual said an xfail test records the assembler's refusal message and so
catches both an error that stops being detected and a message that changes without
anybody meaning it to. It did not. run.sh checked only that the assembler exited
non zero, printed the first line for a person to read, and compared nothing; --bless
recorded nothing for these sixteen tests at all.

So an xfail passed four different ways that look identical from outside: the intended
error fired, an unrelated error fired, the message changed, or the assembler fell over
on its way to the point. That is the documentation describing behaviour the code does
not have, which is the exact failure Tests/docs.sh exists to prevent, in the manual
that argues for knowing what your evidence is worth.

The diagnostic is now stripped of colour, given the same [exit N] line every other
recorded result carries, and compared through check() like anything else. Sixteen
results recorded; every existing one is byte for byte unchanged. Verified the way the
manual asks: one diagnostic was broken on purpose, its test failed with the changed
line in the diff, and its neighbour passed.

Found by ChatGPT reviewing the manual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 18:07:35 -04:00
Anachronaut 79727044b7 Reboot, and the machine device that makes it possible
Until now the only way to restart was to stop the emulator and run it
again, which meant the one thing the machine could not do was the thing
Once was written for. The loop now closes without leaving it:

  > Once /System/Boot/bare.bin
  next start: /System/Boot/bare.bin, once
  > Reboot
  starting again
  stage two
  just this once: /System/Boot/bare.bin
  bare metal: no system, just this

Writing 1 to port 0x13 asks the machine to start over. A PORT RATHER THAN A
SERVICE, because a reset has to work when the system does not: something
only askable through SWI would be unavailable in exactly the case that
wants it most, and a program that owns the whole machine has no system to
ask. It is device class 0x04, in the range kept for the machine rather than
among the peripherals, because it is not one - it is not attached to
anything and cannot be unplugged.

WHAT A RESET REPEATS IS HOW THE MACHINE STARTED. Named an image, the
emulator places it again; named none, the ROM is shadowed again and reads
the disk. Anything else would mean a reset changed what the machine IS,
which is the one thing a reset must not do. Both are tested.

Taken between instructions, because a device cannot restart the machine
from inside the instruction that asked: the CPU is part way through a step
and its state is not yet anything a reset could leave behind consistently.

The disk stays attached and keeps everything written to it - that is what
warm means. The vector table is cleared, which is the one deliberate
departure from leaving memory alone: a vector points into whatever
installed it, and after a reset that program is not running, so a handler
left behind would aim an interrupt at an address belonging to something
gone. It is the argument CosmOS already makes at exit, applied to the
machine.

Reboot is 45 bytes, most of them the word it prints.
2026-08-27 20:56:46 -04:00
Anachronaut 7b28f48f52 Once: start something else on the next start, and only that one
A program that owns the whole machine had nowhere to run. It cannot be
started from the shell, because starting it means there is no shell, and
pointing boot.cfg at it means a machine that keeps starting it - which is a
poor place to find a mistake in something written five minutes ago.

Once writes /System/Boot/once.cfg, in the same format as boot.cfg and read
with the same routines, because a second format for one setting would be a
second format. The loader reads it before boot.cfg and DELETES IT BEFORE IT
JUMPS, which is the only moment there is: after the jump the loader does
not exist.

Consumed by being read rather than by working, so a one shot that hangs
cannot hang twice - the request is gone before the image ran, and the next
start reads boot.cfg like any other.

THE BOOT STATE IS NOT TOUCHED, and the first version got that wrong. It
marked the start the way any other start is marked, and then every
successful bare metal boot reported that it had never arrived - because a
program with the whole machine has no filesystem to clear a mark with and
is doing nothing wrong by not having one. Found by running it: the image
printed its line and the next start still said the last one did not.

Three disks, each a start further along, so none of the tests depends on
another having run.

The loop is closed on the machine now: write it in Edit, assemble it with
Asm, ask for it with Once, restart, watch it own the machine, and the
system comes back without being asked.
2026-08-27 20:02:42 -04:00
Anachronaut 89c667848b Edit read a file into a buffer it never checked the size of
Opening hello.asm showed a thirty one line file as three, one of them cut
short. Opening it again hung the machine: the emulator kept running and
nothing ever answered.

Entry is the buffer a line is read into, and it is followed in memory by
TextHead and ArenaFree - the head of the document, and the pointer its line
allocator hands out. The loop that splits a file into lines copied
characters in WITH NO BOUND AT ALL, so a 94 character line wrote thirteen
bytes over both of them. The list head then pointed into the middle of the
text and the allocator handed out an address inside the file, which is why
the second open walked a list that led back into itself for ever.

Typing was always safe. osReadLine is told how much room there is, so a new
document behaved perfectly and a source file did not - which is exactly how
the user found it, and why it looked like a mystery rather than a bug.

The bound is there now, and the buffer is 128 characters: what a line is
everywhere else on this machine, the same number configuration files use,
rather than a second answer to a question already answered. hello.asm fits.

A file with a longer line is REFUSED rather than shortened. This is an
editor - a line cut on the way in would be written back cut, and the file
damaged by having been looked at. It says so and exits with a status of
one, which it can do since this afternoon; the file is byte identical
afterwards, and the test checks that.

Opened twice in the test, because once is not enough to see it: the first
open does the damage and the second is what never returns.

This is the third time this shape has turned up: a buffer written past its
end into the variables that happened to follow it. The prompt walked off
CwdText into the shell's own command names; the assembler's output ran into
its label table. Every one was found by a person using the machine.
2026-08-27 19:41:51 -04:00
Anachronaut 87d819847e A program can say how it went
SWI osExit takes a status in A, and the shell keeps it. Fifty eight exits
across twenty three programs now say deliberately whether they worked: 25
did what they were asked, 24 did not, 9 were asked wrongly. Compare is the
exception and says so - one there means the files differ, which is a result
rather than a failure, the way diff has always had it.

IN A RATHER THAN Q, which is not a departure from the rule that a service
answers in Q. This one takes an ARGUMENT, the way osPrintNumber takes A and
B, and it never returns to answer anything. A is free precisely because a
return would have put it back - and Q is the ALU's output, so a small
number costs four instructions there against one in A.

The shell does not print it. A program that failed has already said so in
words and a number beside that is noise, so osLastStatus hands it back and
Status is the program that shows it. That indirection is the point: the
number exists for the thing that cannot read words.

MARKING THE EXITS FOUND A DEFECT ON THE FIRST RUN. Type and More printed
why they had failed and then fell through into the success exit, reporting
that all was well. Nobody had noticed, because while the only reader was a
person, the person could see both the complaint and the claim.

Two smaller things. Snake sets the console to line mode and then exits with
zero, and the linter flagged the second RSTA as redundant - an exit status
and a console mode, equal by accident, which is the class that must never
be collapsed. And the README still taught answering by writing into the
frame, three months of habit that SRET replaced yesterday; that section is
gone and the one describing SRET stands in its place.
2026-08-27 19:16:21 -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 ce2a2cd7e6 Settle is a program, and a machine with no fallback still starts
The boot state opened a loop that could not be closed from inside: the
machine said "settle it to try again" and gave you no way to do so. Settle
closes it, in 349 bytes.

A PROGRAM RATHER THAN A SHELL WORD. The shell is for the things that cannot
be done without it, and this is not one - it reaches the system through SWI
like anything else, which means it can be replaced, left off a disk, or
called by whatever comes to call programs in turn. That last one is the
point: a shell word is not callable by anything.

Two services for it. osBootState answers in Q, and a machine with no disk
answers settled, because there is nothing there to be unsettled about.
osBootSettle puts it back. SETTLING IS THE ONLY WRITE A PROGRAM GETS -
marking a start as trying or fallen back is the loader's business, and a
service that let a program claim either would let it lie about something
the loader has no way to check.

And a hole the tests walked into, which was mine rather than theirs. With
no fallback configured, a failed start left the machine unable to start at
all: the mark said do not use the system, and there was nothing else to
use. That turns "the last start failed" into "no start is permitted", which
is worse than the problem the mark was added to solve. With nothing to fall
back to it now tries the configuration again and says so - a failure that
was passing recovers, and one that is not leaves the machine exactly where
it would have been without any of this, which is the most that can be
promised when there is only one thing to start.

docs.sh caught both new services having no row in the services table before
anything else did.
2026-08-27 17:02:59 -04:00
Anachronaut dc74149321 B4: the disk remembers whether the last start arrived
The loader marks the superblock before it hands over and the system clears
the mark when it reaches its prompt, so a system that crashes on the way
there leaves it set. The loader finding it still set next time is how a
machine that will not start says so to the only thing in a position to do
anything about it. Without that, pointing boot.cfg at something that dies
before the shell is a machine that can never be told anything again - the
shell is the only way to change the file, and the file is what stops the
shell from starting.

Three states rather than two, and the third is the one worth having:

  0 settled    the last start arrived; use the configuration
  1 trying     handed over, and nothing came back to say it got there
  2 fell back  a try failed and the fallback was used, until settled

With only 0 and 1 the machine alternates for ever: fall back, reach a
prompt, clear the mark, retry the broken system, crash, fall back. State 2
stops that. A system known not to start is not tried again until somebody
says the situation has changed.

REACHING THE PROMPT IS A DELIBERATE THRESHOLD. It is not a claim that the
system works - a shell can be reached by something broken in every other
way. It is the point where a person can type, which is exactly what the
fallback exists to give back: anything wrong past there is fixable from the
prompt and nothing wrong before it is fixable at all.

The routines live in sbfs.asm because both the loader and the system read
and write this byte, and two pieces of code with their own idea of where a
byte lives is what this format has two implementations and a byte for byte
comparison to avoid.

And the trap this system documents in its own manual caught me anyway: the
first version handed the state back in A, which CALL restores, so every
read got whatever the caller happened to be holding. It comes back in
memory now, and the comment says why.

Three disks differing only in the state on them, so the tests read as three
consecutive starts of one machine while none depends on another running.
2026-08-27 16:51:39 -04:00
Anachronaut 546f336823 Configuration files, and boot.cfg as the first of them
One setting to a line: a key, a space, the rest of the line is the value.
A semicolon starts a comment. The format was noticed rather than designed -
textSplit already cuts the first word off a line and leaves the rest, and
textSame already insists two strings end together, so reading a setting is
those two routines and a loop. It is also what the shell reads, which makes
a configuration line a command line the machine reads instead of a person
typing one.

The format was chosen by asking what the BOOT LOADER could manage, because
it is the worst case in every direction: a few kilobytes, no operating
system to report to, and if it fails the machine does not start. Two
formats would be worse than one and the loader cannot have the richer one.

CONFIGURATION IS ADVICE. A missing file, a missing key, an unusable value,
a line too long to read: all of them mean use the default and none is a
failure. BUT QUIET IS NOT SILENT - a setting somebody meant, which did not
take effect, says so. That was the user's addition and it is the better
rule: the default alone leaves the only symptom being that the machine did
not do what somebody asked.

So two routines. cfgGet reads and says nothing, because reading three
settings should not report one bad line three times. cfgCheck reads the
file once and reports, and is handed the caller's list of keys - whether a
key means anything is the only part of this a shared reader cannot judge.

/System/Boot/ holds the boot files, and stage two reads boot.cfg for what
to start, with a fallback to try if it does not work and a name compiled in
for when the file says nothing.

THE TEST FOUND A REAL BUG, and it is the interaction I would not have
thought to look for. First-match-wins met an empty value: a file with

  system
  system /System/Boot/bare.bin

matched the first line, handed back nothing, and the machine tried to start
a file with no name while a good setting sat underneath. An unusable value
is an absent one - which is what "configuration is advice" says, and this
is where it earns its keep.

cfgBare starts an image with no operating system in it at all, which is
what loading an ordinary boot image buys: a program wanting the whole
machine is a file like any other, chosen the same way the system is. Three
disks differing ONLY in boot.cfg, so each is a test of the file rather than
of the machinery under it.
2026-08-27 16:04:21 -04:00
Anachronaut 54ff7196c9 Stage 0: the emulator carries the ROM, so a disk is enough
./SplitBit --disk system.img
  stage two
  CosmOS
  >

No boot image named. The emulator shadows its built in stage one into
Program Memory - boot vector included - and the CPU then does exactly what
it has always done: reads the boot vector and starts where it points.
NOTHING ABOUT THE CPU CHANGED to make a machine that starts itself, which
is what picking shadowing over a mapped ROM bought.

The ROM is generated from Programs/Boot/stage1.asm by the makefile rather
than committed beside it, because a copy of a program kept next to the
program is a copy that goes stale. That makes the assembler a real
dependency of the emulator, which it always sort of was and now says so.
od and awk rather than xxd, which is not everywhere, or python, which the
README does not ask anybody to install in order to build this.

loadROM is loadFile given bytes instead of a path: both go through one
reader over an fmemopen stream, because a ROM is a boot image and there is
no reason for the machine to have two ways of understanding one.

Naming an image still works and is what every other test here does. That
path is not a shortcut to apologise for - placing memory from outside is a
real thing real machines allow, and it is a debugger. The help says so now.
No image and no disk is the one case with nothing to run, and it says that
rather than printing a usage message about a missing file.

run.sh gained a "rom" mode which hands the emulator a disk and nothing
else. The source column still names stage1.asm, because that is what is IN
the ROM: assembling it there says the thing the emulator carries is a thing
that still assembles.
2026-08-27 14:41:33 -04:00
Anachronaut c312853f8e The machine starts itself
stage two
  CosmOS
  > saved it
  read it back, 22 bytes:

Stage one hands over to stage two out of a boot slot; stage two mounts the
filesystem, finds /System/cosmos.bin, takes the image apart and places its
code, its data and its vector table, and jumps to the entry point the
vectors named. Nothing placed memory for it.

What it loads is an ORDINARY BOOT IMAGE, the same SPBT file the emulator
has always been handed. That was the user's call and it is the whole trick:
a second stage that loads the machine's normal image format is not a
boot-specific mechanism, so bare metal SplitBit stops being a special case.
A program wanting no operating system under it is just an image, written
under CosmOS like any other, and startable because it is a file.

Three things in it worth knowing:

- THE ENTRY POINT IS CAUGHT ON ITS WAY PAST. Program Memory cannot be read
  back, so the boot vector cannot be looked up after being installed; the
  vector loop notices the one addressed at 0xFC00 and keeps it.
- A missing "VEC" is not a fault. An image written before vectors existed
  simply ends after its data, and then the entry point is zero, which is
  what every such image has always relied on.
- Feature flags that are set mean an image asking for a machine this may
  not be, and the honest answer to a request that cannot be understood is
  to refuse rather than to run it anyway.

The test records that the system WORKS afterwards rather than that it
started. A loaded program running is what says the vector table arrived,
because a program reaches the system through SWI and nothing else; the file
written and the directory entered say the filesystem and the console came
up with it. A second disk has a boot slot and nothing to start, and says so
rather than jumping somewhere.
2026-08-27 14:13:23 -04:00
Anachronaut 82adeeb193 A boot payload can arrange its own Data Segment
Stage one places Program Memory and nothing else, because knowing where a
payload's data ends and its code begins would mean knowing a format, and
knowing formats is what ROM must do as little of as possible. But the real
second stage needs a Data Segment: sbfs.asm has variables and a string it
compares against.

The answer needs nothing new. A loadable image is written into the slot as
code followed by data, so the data image is already in Program Memory just
past the code - and the payload's first instructions blit it down to where
it was assembled for. Proved by slotData.asm, which prints from a string it
placed itself.

The padding is the part worth recording. The blit needs a length and the
assembler will not work out the difference between two labels, so the
segment is padded to a round number and that number is what gets copied.
The first draft padded to 257 and copied 256, and the byte that did not
arrive was padding, so it worked by luck. It is exact now and says why.

This is the shape the user asked for and it goes further than the
mechanism: the second stage becomes a loader for the machine's ORDINARY
image format rather than for anything boot-specific, so bare metal SplitBit
stops being a special case. A program that wants no operating system is
just an image, developed under CosmOS like any other, and selectable at
boot because it is a file.
2026-08-27 13:45:11 -04:00
Anachronaut d07b23f90b Rung 2: the machine starts itself off a disk
Stage one exists and works. It is 330 bytes of program and everything it
knows is a thing that will be true forever: which port the disk is on, that
a SplitBit disk begins with its own name, and where two numbers sit in that
first block. Not what a file is, not what a directory is, not that SBFS has
versions. It reads the live boot slot into Program Memory, jumps to the
first byte, and prints one character and halts if there is nothing there.

It is an ordinary boot image for now, so the whole chain runs on machinery
that already exists and the emulator has not been touched. Nothing about it
changes when it moves into ROM except who puts it in memory.

SplitDisk gained "boot" to write a slot and "bootslot" to choose one, kept
apart on purpose: writing a slot and starting from it are different
decisions, and joining them would make every write a commitment. A slot is
always written WHOLE, because one still holding the tail of what was there
before is one whose contents depend on its history, and stage one reads all
of it without knowing where the file stopped.

Three recorded tests, and the pair is the point: two disks differing only
in which slot the superblock names, with payloads that say different
things. One prints "booted" and the other does not, so this is a test of
CHOOSING a slot rather than a test that some bytes were read. The third
boots a disk with no boot area and gets the one character a ROM has room
for. Eight more host checks, including that a slot is padded whole.

Two things worth recording. The first draft used #Align to put the scratch
buffer at 0x8000 and produced a 33K file - thirty two kilobytes of zeroes
in something meant to be a ROM. It is an address, not storage, which is
exactly what the assembler's own scratch map exists to say.

And SplitLint caught the second in code written an hour after the baseline
that catches it. In the blit set-up, RSTA writes a source address of zero
and then RSTA writes a bank number of zero - two unrelated quantities that
are equal by accident, in the most safety critical file in the repository.
It is marked with a reason rather than removed.
2026-08-26 23:31:04 -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
Anachronaut ce0f18f4ef Refuse a directory whose last entries cannot be named as a parent
A parent is an entry index PLUS ONE in two bytes, so entry 65535 has no
parent number: adding one wraps to zero, and zero is the root. Eight
entries to a block, so 8192 directory blocks reaches it and SplitDisk
formatted that happily.

It does not fail by refusing, which is why it was worth chasing rather than
reasoning about. Reproduced on a disk built for it: mkdir /deep/child, with
/deep at entry 65535, printed 'Made "/deep/child" as entry 0' and put child
in the ROOT. Listing /deep then showed nothing, because the search is for a
parent of 65536 and the entry carries zero - so the same mkdir succeeded
again, and again, and five entries called /child piled up in the root.
Duplicate names in one directory are the one thing rename refuses outright,
on the grounds that a search answers with whichever it meets first and the
rest can never be reached; this manufactured them one per attempt.

8191 blocks is the most, giving 65528 entries. Refused when formatting and
again when reading, in both implementations, because a disk claiming more
was made by something that never checked. On the machine only the high byte
of the count has to be looked at: anything from 0x20 up is too many.

Three checks, all of which fail with their guard removed. The machine's
disk claims the size rather than having it, so the test image is 64 blocks
that lie rather than sixteen megabytes that do not - mounting is refused at
the geometry, which is read out of block 0.
2026-08-25 23:47:02 -04:00
Anachronaut 2b5506ee70 Stop the prompt writing off the front of its own buffer
The prompt is the working directory's path, worked out each time by walking
the chain of parents up to the root. The names arrive deepest first, so
they are written backwards from the end of a 127 byte buffer - and nothing
bounded that walk.

Nothing bounds the depth either. A path given to one operation is capped at
95 characters and a 22 character name, but "mkdir a" and "cd a" are each
far inside that and can be repeated forever. Six directories of 22
characters is 132 characters of path, and at that point the walk wrote down
past the front of CwdText and into what the assembler had laid out below
it: the shell's own command names. ExitName sits five bytes under, so the
word "exit" went first and the shell stopped recognising the command for
leaving. Measured, not deduced: fine at five levels, gone at six.

The walk now counts the room it has left, byte by byte, and stops. What is
already written is the DEEP end of the path, which is the end worth
showing, so it is cut at the front and three dots say so - out of three
bytes held back from the count, so there is always somewhere to put them.
Twenty levels deep the prompt shows the last five and every command still
works.

cosmosDeep records that, and records it by running help, cd and exit from
down there rather than by looking at the prompt: a wrong prompt is
cosmetic, and this was writing into other variables. It fails with the
bound removed. The tree is built by SplitDisk because a path that long
cannot be given to mkdir in one piece - which is the same fact that makes
the depth unbounded.

The three path limits are written down in the README now, including which
one actually binds. The other two do not: the longest path on a full
install is 21 characters.
2026-08-25 23:35:33 -04:00
AnachronautandClaude Opus 5 d4cba36c5e Devices that take time, and a filesystem that waits for one
The disk's status has always had a bit meaning "still going", and the header
beside it has always said to honour it. Nothing did, because nothing could: the
host finished the transfer inside the instruction that asked for it, so the bit
could never be seen up and asking about it was asking about something that
cannot happen.

--disk-cycles gives it a latency. The command is still checked at once, because
a refusal is not work - a block that is not there fails before any head moves -
but the transfer is remembered and done when the machine has run that far. Until
then the buffer holds the block BEFORE this one.

That last part is the point. A program that does not wait gets the wrong bytes
rather than an error, which is the failure the bit exists to prevent and the one
that would never have shown up. With a latency of two thousand, CosmOS could not
even mount: sbfsMount reads block zero and looks straight at the buffer.

deviceTick is the general shape rather than a disk feature. Called once per
instruction with the machine's clock, it lets anything whose moment has come
finish - which is what a display that refreshes, or a port that waits on the
host, would want in exactly the same way.

The filesystem watches the bit now, in one small routine reached with RCAL. That
is not decoration: what it hands back is the settled status in A, and CALL puts A
back the way it found it, so an ordinary call cannot carry the one thing this
exists to carry. Two bytes of Stack rather than ten, in a routine that runs on
every block the machine ever touches - the first place in the system where the
new call is the right one rather than merely a cheaper one.

The manifest takes a @N after a disk, the way it already takes :ro, so a test can
ask for a slow one. cosmosSlowDisk lists a directory at two thousand cycles a
block and gets the same listing as everything else, which is the whole assertion:
a filesystem that did not wait would print nonsense rather than fail.

Zero is the default and every other test runs at it. What waiting costs, on a
directory heavy run: 229k cycles at zero, 275k at five hundred, 415k at two
thousand, 1.16M at ten thousand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 21:29:57 -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 a7d3e09d94 Refuse a streamed file that commits more than it reserved
osFileStart sets an extent aside and osFileWrite refuses a block index outside
it, so writing off the end was already barred. Committing a larger size was
not, and reaches the same neighbour by simply claiming it: a directory entry is
the only record of what a file owns, so an entry claiming a block it was never
given owns it, and so does whatever owned it before. Both files then look
perfectly well formed. The free count went backwards past zero on the same
path.

Found by ChatGPT's review of the streaming work, in NOTES.md. I had bounded the
index because writing off the end was the obvious way to reach a neighbour, and
had not noticed that the other end of the same reservation was open.

THE SIZE IS COMPARED, NOT THE ROOM IT TAKES UP. One block and a tail occupies
exactly what two whole blocks occupy, so bounding the blocks alone would let a
file reserve the first, commit the second, claim no block it was not given, and
still report two hundred and forty six bytes that were never written to it -
whatever the disk had there before.

Checked before anything is touched, which is why the temporary is found twice.
The old file is deleted a few lines down and a refusal after that point would
have destroyed the thing it was protecting.

AND IT CAUGHT A REAL ONE IMMEDIATELY. The assembler reserves the file plus room
for its vectors, and asked for four bytes per vector DECLARED - which looks like
a safe bound and is not, because a device is declared during the SECOND pass, in
the line that implements it. A program with a device installs a vector that was
not counted when the room was measured. CosmOS reserved 14,163 bytes and
committed 14,167, writing four bytes past what it had been given on every build
since S2. It landed inside the last block it owned, and would not have if the
boundary had fallen four bytes earlier.

It reserves against the vector table's LIMIT now, which cannot go stale whenever
things are counted.

Claim.asm is the program that tries it: reserve one block and a tail of ten,
write them, then tell osFileDone the file came to two whole blocks. The refusal
and the honest commit that follows are both recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 20:20:39 -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 00d896e3e7 Break shows registers it chose, not ones it inherited
Break left DP1 and DP2 alone, so what a stop printed for them was whatever the
shell happened to have left there - which is a CosmOS address, which moves
whenever CosmOS is touched at all. The recorded output had to be taken again
four times in one day's work, every time for a value that is not this program's
and that nothing should ever depend on.

It sets all three of the pointers it owns now, rotated between the two stops so
that every one of them visibly changes, the way A and B already did. DP3 stays
as the system left it: it is where the program was entered, which is the one
thing worth seeing here that this program did not choose, and it is steady
because it is this program's own base.

A demonstration of what the registers were should show registers somebody
chose. Then every line of the record is being asserted rather than merely
observed, and a reader can tell which is which.

Checked both ways: sixty four bytes added to CosmOS's data no longer moves it,
and reading the frame at the wrong offset still fails it.

cosmosRun and cosmosMonitor move because Break is sixteen bytes longer and both
of them list the disk it sits on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 17:13:49 -04:00
AnachronautandClaude Opus 5 9f7dffdeca S1: the write side learns to stream
osFileStart, osFileWrite and osFileDone are the mirror of osFileInfo and
osFileBlock. A program can now write a file it never holds: Pour writes twelve
blocks and a tail while keeping 256 bytes of it at a time, and the host tool
reads all 3,112 bytes back with every block where it was put.

ONE WRITE IS OPEN AT A TIME AND COSMOS HOLDS IT. Reading needs no state - a name
and an index are the whole question - but writing safely does, because the new
file has to exist before the old one is thrown away and something has to
remember which temporary belongs to which name. Keeping that here means the
careful order is written once instead of in every program that streams.

Nothing already on the disk is touched until osFileDone, so a disk without room
says so while the old file is still there. That is stronger than osFileSave can
manage, where the size is only known once the caller has every byte in hand.
osFileSave stays: Edit and Files hand over whole documents and have no reason to
learn any of this.

osFileWrite refuses an index past the end of the file, and that refusal is not
politeness. Files are contiguous, so block nine of a three block file is a real
block belonging to something else, and writing it would put one file's bytes
inside another with nothing anywhere saying so. Checked both ways: the tail
block is allowed and the one past it is not.

Three bugs, all of them the same shape - a register or pointer used for two
things at once:

DP3 carried the block count in and was popped high byte first, which is the
wrong way round from every reader in the system and made the count two hundred
and fifty six times too big.

sbfsStreamStart took the name in DP0 and then wanted DP0 for something else
before it had read it, so it walked whatever it last pointed at and reported
that it could find no room.

sbfsStreamWrite kept the caller's block in DP3 across a find - DP3 being the
pointer a return does not put back, which is exactly why the find uses it too.
What went to the disk was whatever the scan last looked at. It goes in memory
now, and the file is correct because every block says which block it is; a
check on the length alone would have passed all three of these.

Writing no longer finds the file for each block either. Nothing moves a file
once it is made, so where it starts is settled when the temporary is created.
That was not even slow - a scan stops the moment it matches - but it was a walk
of the directory per block for an answer that cannot change, and it is 28 per
cent of the cost of writing forty blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 14:53:37 -04:00
AnachronautandClaude Opus 5 da91a36d92 D4: the machine makes directories too
mkdir and rmdir are the machine's own now, and a file goes where its path says
rather than always in the root. A disk can be organised without the host tool
touching it.

Everything below the surface works in terms of a directory and a name rather
than a path. sbfsWalkParent splits the last name off, walks the rest, and hands
back the two - and the separator stays on the end of the head, which is what
makes one rule cover every kind of path: "/x" leaves "/", which is the root;
"x" leaves nothing, which is where the machine already is; and "A/x" leaves
"A/", which is neither and needs no special case to say so.

Saving works in those two as well, and had to. The careful order a save uses -
make a temporary, write it, delete the original, rename the temporary - only
works if the temporary is made in the SAME directory as the file, because the
rename at the end changes a name and does not move anything. Renaming to a path
naming a different directory is refused for that reason, rather than quietly
being a lie the disk goes along with.

Three things this cost, all found by running it:

mkdir Apps/Deep made /Apps/Apps. The leaf was worked out into SbfsWanted and
then the head was walked - and walking goes through sbfsPathNext, which puts
every name it meets into SbfsWanted on the way past. The head's last name
landed exactly where the leaf was. It has somewhere of its own now.

rmdir took a directory with something still in it, which is the one failure the
whole design is arranged to prevent. Looking for children clobbered DP2 and
rebuilt it from the buffer and the entry count with the subtraction the wrong
way round, so the pointer walked off the end of the block and found nothing.
The comparison goes through a CALL now, like the two beside it, and DP2 comes
back on the entry because a RET puts it there. SplitDisk's "in use but not
reachable from the root" line is what caught it.

Refusing a name longer than twenty two used to read the twenty third character
of a shorter one, which is somebody else's string. It is measured now.

Tests/agree.sh is new and is the gate this rung was for: the same disk built
twice, once with SplitDisk and once with CosmOS, compared byte for byte. The
two share no code and only a written specification, and every field one writes
and the other only reads is checked there and nowhere else - which entry a
thing lands in, which block, what a directory's unused fields hold, the
version, the free count. It caught a wrong parent immediately when that was
broken on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 09:18:29 -04:00
AnachronautandClaude Opus 5 36ce9f6ccf D3: the machine knows where it is
cd moves it, dir lists the directory it is in, and the prompt says which one -
but only when that is not the root, so a machine nobody has moved about on
looks exactly as it always did and every recorded test that never says "cd"
keeps its recorded prompt.

A path beginning with a separator is measured from the root and anything else
from where the machine is, so a bare name means a file in the current
directory. NO PROGRAM HAD TO BE TOLD: the working directory lives in sbfs.asm
beside the thing that resolves paths, because it is what a relative path MEANS.
Keeping it in the shell would have meant either handing it down on every call
or pasting it onto the front of every name, and the second of those is how a
name that is already absolute gets ruined.

Nothing stores the path. The working directory is an entry index and two bytes,
and the text on the prompt is built each time by walking the chain of parents
upward, writing names from the end of a buffer towards the front - which is the
order they arrive in, and saves reversing them afterwards.

sbfsFind splits into a walk and a check. "cd /" and "cd .." both end at the
root quite legitimately, and had no way to say so through a routine whose only
word for the root was "missing".

Typing a program's name now tries two places in order: where you are, then
/Apps. The first makes a program you are working on the one that runs; the
second lets Snake work from anywhere. A word already beginning with a separator
has said where to look, so only that place is tried.

osChangeDir exists so that "a program may move about, and the shell puts the
working directory back" is a thing that can happen rather than a promise about
nothing. Both halves of that were unfalsifiable without it: with no way for a
program to move, removing the restore changed no test. Wander is the program
that moves - it goes where it is told and reads a file there by a bare name -
and with it on the disk, removing the restore fails.

The remembered file is dropped whenever what a relative path means changes: a
cd, a program calling osChangeDir, a program exiting. Removing all of them
fails the test and removing any one of them does not, because today every path
into that cache belongs to a program that exits. It is kept in all three
because the cost is a call and the failure is a file's blocks being handed out
under another file's name.

The cwd fixture holds two files called notes.txt saying different things, and a
Say.sbx in /A that is really hello. Two copies of one program, or two copies of
one file, would have passed with the whole of this deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 22:22:45 -04:00
AnachronautandClaude Opus 5 36a1b07b5b D2: the machine walks a path
sbfsFind takes a path where it used to take a name: names with '/' between
them, walked from the root, with '.' and '..'. Each name is looked for among
the entries whose parent is where the walk has got to. A bare name is a path of
one name, so everything written before directories existed still works and
still costs one walk of the directory.

sbfsMount takes either version. On a version one disk every entry has zeroes
where a parent goes and the walk starts at zero, so the comparison always
agrees - which is how a flat disk reads correctly here with nothing done to it.

PROGRAMS DID NOT HAVE TO BE TAUGHT ANY OF THIS. Resolution sits inside
sbfsFind, below the services, so every osFile call keeps its signature and a
path is simply a longer name. Type, More, Edit and the assembler gained
subdirectories without a line changing in any of them.

Four things this turned up, none of which was the path walk:

load copied the path into a buffer sized for a NAME, so anything over 22
characters was cut short - and cut short into a path that often still resolved.
"/Apps/Deep/../../Apps/Say.sbx" became "/Apps/Deep/../../Apps/" and reported
that the program was a directory. That is the whole of what looked like a bug
in '..', and it cost most of the time here.

load on a directory SUCCEEDED. A directory has no blocks, so reading it reads
nothing and leaves the staging area holding whatever was staged last - which,
if that was a program, still says SBEX and still has a working entry address.
It handed back the program before it. Refused outright now.

delete and rename on a directory are refused, and save refuses one up front
rather than failing at the rename and leaving a temporary behind. Deleting a
directory frees an entry index, and a parent IS an index, so the next file
created would take it and inherit the children.

create writes the parent rather than leaving it zero by luck. It would be zero
- delete wipes all thirty two bytes and a fresh entry never had any - but that
is a fact about two other routines, and a file appearing inside a directory it
was never put in is not a failure anybody would think to look for.

dir marks directories and counts them apart from files, because at this point
it was calling them files of no bytes.

Two hazards written down in the design note turned out not to be real, and both
were checked rather than argued about:

The lookup cache holding 22 bytes of a longer path cannot hand back the wrong
file - textSame wants both strings to end in the same place, so a cut down
entry misses. It can never HIT either, though, so every path longer than a name
went to the disk every time; it holds a whole path now.

The allocator stepping over directories changes nothing any test can see. A
directory has no start as well as no blocks, so its bounds are nought to nought
and no candidate begins before it ends. The four instructions stay, with a
comment saying they are not load bearing today and why they are there anyway.

makedisks.sh resolves its build path before it cds. Given a relative one it
carried on and quietly built disks missing some of their files, which is how
the tree fixture lost a file and sent me looking for a bug in '..'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 19:08:26 -04:00
AnachronautandClaude Opus 5 2b0aeeefd4 Start a program by typing its name
A word the shell has no command for is now looked for on the disk as
"<name>.sbx", and if it is there it is loaded and started exactly as load and
run would do it. Whatever followed the word reaches the program through
osArgument by the same route as whatever follows run, so "Say hello there"
and "Type notes.txt" work without either program knowing how it was started.

load and run are unchanged and both stay. load is how the monitor puts an
arbitrary file in front of itself, which typing a name deliberately cannot do:
the extension is added rather than assumed, so "notes.txt" looks for
notes.txt.sbx and a text file is unreachable by name whatever is inside it.

Three things this had to get right:

The built-ins are tried first and always win. The search hangs off the end of
the dispatch chain, so a file called dir.sbx cannot become dir, and the
commands worth trusting when the disk is what you are doubting stay
trustworthy. The invoke disk carries a working dir.sbx so that this is checked
rather than asserted.

A file that is found but is broken says so. "not a program" and "I do not
know" are different answers, and giving the second about a file sitting on the
disk would send somebody looking in the wrong place. loadProgram therefore
hands back a status as well as a message, since only "no file of that name"
can fairly be reported as anything other than a fault.

doLoad became that subroutine rather than being copied. It ends in RET instead
of a jump to the prompt, and each way of failing sets its number and its text
together so a new one cannot leave half of the answer behind.

cosmosBreak moves because Break prints the pointers it was handed and those
are the shell's leftovers, which a CALL now puts back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 17:25:50 -04:00
AnachronautandClaude Opus 5 dbe58db660 Add Type and More, and the file stream they are built on
Two applications that read a file too big for Data Memory: Type prints one,
More pages it. Both sit on fileStream.asm, which wraps osFileInfo and
osFileBlock into open-and-next so an application walks a file's blocks without
repeating the service calls.

The disk fixture is deliberately awkward: readable.txt crosses several blocks
and carries no zero byte to be mistaken for an end marker, and empty.txt says
that zero blocks is a valid file rather than an error.

These three files were written by ChatGPT, as their headers record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 17:15:53 -04:00
AnachronautandClaude Opus 5 ccf4b384e1 Give Programs/ one rule: a directory per kind, nothing loose
Five .asm files sat at the top of Programs/ beside six directories, with
nothing to say which a new file should join - and hello.asm, which is the
native assembler's first target and named in sixteen places, looked like a
stray.

    Programs/
      Examples/     what you read to learn: hello, printHello, inputTest,
                    replCalculator, and Fibonacci, primeSieve and gameOfLife
                    as sets of their own
      Libraries/    included by name, no entry point of their own
      Loader/       loader.asm, and the loadable program it reads
      CosmOS/       the system, its applications and its assembler
      testPrograms/ what 'make test' drives

Loader/ is the one worth explaining. loader.asm is not a demonstration: it
reads a program off a disk, puts the two pieces where the header asks, and
jumps to the entry. CosmOS grew out of it and does the same thing as one of
its commands. It is kept because backward compatibility with the simplest
version of the system is a standing goal, and it was sitting loose next to
the demos as though it were one.

Programs/loadable/ was a directory holding one file called hello.asm - a
third thing of that name, and the name said nothing about why it was there.
It is Loader/loadable.asm now, beside the loader that reads it.

Every reference moved with them: the makefile's program list, twelve
manifest lines, makedisks.sh, native.sh, and four paths across the README
and both manuals. Verified by deleting both build directories and running
the whole suite from nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 13:38:47 -04:00
AnachronautandClaude Opus 5 fb335681d2 M4: SplitBit assembles SplitBit, and then assembles itself
> load Asm.sbx
    > run cosmos.asm
    wrote cosmos.bin: program 7036, data 2448, labels 475
    > run Asm.asm
    wrote Asm.sbx: program 7533, data 4099, labels 555

Both byte for byte identical to what the host assembler builds from the
same source. The machine now builds the operating system it is running on,
and builds the thing that built it.

THE CHECK THAT MATTERS MOST IS THE THIRD ONE. A binary that matches could
still have come from an assembler wrong in some way this particular source
happens not to exercise. So Tests/native.sh boots the CosmOS that CosmOS
built and has THAT assemble CosmOS again - and the second generation is
identical to the first, down to the cycle count. It is a fixed point: the
machinery has been through itself. After this the host is a convenience
rather than a necessity.

WHAT STOOD IN THE WAY was not the assembler. It loaded, faulted at 7,780
cycles, and the fault was in CosmOS: a loaded program is staged at 0x8000
before being blitted into place, so the whole FILE has to fit in the 32,768
bytes above it. The assembler's file was 33,983, and 22K of that was
zeroed scratch buffers - because #Reserve emits what it reserves.

None of that is initialised data. It is scratch, wanted only while the
assembler runs, and while it runs everything above its own data is free.
So the buffers are a MAP now rather than declarations - Assembler/scratch.asm
writes down six addresses and the file carries none of it. 33,983 bytes
became 11,648, and the assembler could load itself.

The map has a file of its own because the reader and the label table both
need addresses out of it while neither includes the other.

The sizes are cut to the largest thing it is asked to build, and that turns
out not to be the operating system: the assembler is 555 labels and 11,648
bytes of output against CosmOS's 475 and 9,564. The hardest thing this
assembles is itself.

Also: sizing it for CosmOS meant raising the label table, and raising the
label table is what pushed the file over the staging limit. The two facts
only met because the first one was tried.

Speed, measured rather than guessed: CosmOS takes 80,168,646 cycles, which
is eighty seconds of emulated time and under a second under --fast. Most of
it is a straight walk of 475 label names, several thousand times. Sorting
or bucketing that is easy and was deliberately not written before there was
something to measure.

make run-cosmos now puts every source file on the disk, so the whole thing
can be done rather than read about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 12:03:31 -04:00
AnachronautandClaude Opus 5 dcb331c151 SplitBit assembles SplitBit: M1, a single file with no includes
Programs/CosmOS/Assembler/ is an assembler written in SplitBit assembly. It
runs under CosmOS, reads source off a SplitBit disk, and writes a binary back
to it with no host involved anywhere:

    > run Asm.sbx hello.asm
    wrote hello.bin: program 17, data 14, labels 2

THE ACCEPTANCE TEST IS THE BYTES. Tests/native.sh assembles Programs/hello.asm
both ways and compares the two files byte for byte, then runs the one the
machine built. "It ran" and "the sizes look right" both pass for a binary with
a label one byte out, which is a program that jumps into the middle of an
instruction - so the only honest test is the one SplitDisk and sbfs.asm
already work under: two implementations of one written specification, each
checking the other. The files are identical and the result prints Hello,
World! in 70 cycles.

hello.asm is the target because it is the oldest program in the repository.
The first thing this machine ever ran is now the first thing it assembles for
itself.

TWO PASSES OVER STREAMED SOURCE. The C assembler reads every token of every
file into one array; that cannot port, because cosmos.asm alone is 56,047
bytes against 64K of Data Memory. The native one streams through a 256 byte
window, twice, and keeps only the label table between the passes. Two passes
suffice because every length is known without resolving anything - an
instruction's from its shape, a value's is one, a string's is its characters
and a zero - so the first pass fixes every address and the second never needs
a fixup list. A forward reference stops being a special case and becomes the
reason there are two passes at all.

The parts, each checked before anything was built on it:
  source.asm    characters out of a file of any size, with a line number
  token.asm     tokens out of characters, one character of lookahead
  classify.asm  what a token is, in the C assembler's order, which IS the
                language: keyword, instruction, value, string, label
  labels.asm    names packed in an arena, four bytes of index each
  numbers.asm   sixteen bit arithmetic, since sbfs.asm's cannot be reached
  table.asm     the instruction set, generated by the same script the
                monitor's copy is, and now BOTH are checked by docs.sh

readTest.asm and tokenTest.asm check the reader and the tokenizer on their
own, recorded as cosmosSource and cosmosTokens. A wrong classification does
not produce a wrong byte somewhere obvious; it produces a right looking
program of the wrong length, so it is worth catching where it happens.

WHAT IT REFUSES: #Include, #Base, #Align, #Reserve and #Vectors are refused
by name rather than ignored. Skipping a directive would produce a file that
looked right and was the wrong length, which is the worst thing an assembler
can do.

Two traps worth recording, both already known to this project and both hit
again: CALL restores A, B and DP0-DP2, so three routines returning an answer
in A had it undone by their own return; and numStep works on DP0, so three
sites that set DP1 left a pointer that never advanced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-20 22:13:15 -04:00
AnachronautandClaude Opus 5 a131a90c67 A string that spells a directive is a string, not a directive
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
2026-08-20 22:12:47 -04:00
AnachronautandClaude Opus 5 3d2ab34229 Streaming: read a file bigger than the machine's memory
osFileRead hands over a whole file, which settles anything under 64K and
settles nothing above it. CosmOS's own source is above it - the sources
together are 104K against 64K of Data Memory - so a machine that is going
to assemble itself needs another way to ask.

osFileInfo (0d26) says how many blocks a file occupies. osFileBlock (0d27)
hands over one of them and says how many of its bytes belong to the file.
Between them a program reads a file of any size through a buffer of 256.

Blocks rather than bytes from osFileInfo is forced, not chosen: a file on a
sixteen megabyte disk is up to twenty four bits long and a pointer holds
sixteen. osFileBlock's count answers in DP3 for the same kind of reason -
a whole block is 256 bytes, which does not fit in a register, and a count
that reported it as zero would make every reader special-case the end.

Nothing is kept open. Every call names the file, so there is no handle to
leak and nothing left behind by a program that stops halfway. Taken at its
word that means searching the directory once per block, so the system
remembers where the last file it was asked about lives; every path that can
change what a name means calls fileForget, including the shell's own delete
and rename, which do not go through the services. Correctness never depends
on the cache - a cache thrown away is indistinguishable from one never
filled. Measured on a 329 block file: 7% of the run saved when the file is
the first directory entry, 11% when it is the sixteenth.

These two say WHY when the answer is no, which the others do not. Elsewhere
the only useful response to a failure is to give up, so one value suffices.
These are asked questions, and running off the end is how a reader learns it
has finished, so it gets an answer of its own: 1 no disk, 2 no such file,
3 past the end, 4 the disk refused.

Apps/Stream.asm reads an 84,000 byte file through 256 bytes. The check that
matters is the second one: a small file read BOTH ways - whole with
osFileRead and streamed - with the two checksums compared, so streaming is
measured against the path already known to work rather than against a number
someone wrote down. The checksum is Fletcher's rather than a sum, because a
sum is the same whatever order the bytes arrived in and the order is exactly
what streaming has to get right. Both checksums were also confirmed against
the same arithmetic run on the host.

The rest of the test is the cache: two files read alternately catch a memory
that missed the name changing, and a rename catches one that missed the file
moving - and that one would otherwise pass, since the blocks are still there
holding the same bytes.

The test file is generated rather than taken from the repository. The CosmOS
sources would be a truer picture and would move the recorded checksum every
time a line of CosmOS was edited, putting a real difference in a crowd of
meaningless ones - the same trap the cycle counts used to set.

cosmosBreak's recorded output moves by two bytes in two pointers: SbfsIndex
added two bytes to the filesystem's data and Break prints the system
addresses the registers happened to hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-20 21:19:36 -04:00
AnachronautandClaude Opus 5 3b800a69e8 Monitor: a line assembler
a <address>, then instructions until a line that is just a dot.

The syntax is the assembler's own: a selector rides on the mnemonic as LDA.0
or LDD.0.1, and leaving one off means Data Pointer 0 exactly as it does in a
source file, so nothing learned at the monitor has to be unlearned when
writing a program. Case is folded, since the assembler does not care either.

Numbers are hexadecimal and bare. A source file writes 0x2000 or 0d16 because
it has both and must say which; a monitor has one and says so once, in the
manual, rather than on every line.

It reads the same table the disassembler does, searched the other way round,
which is the point of it being a table rather than two lists: what a writes,
d reads back, and neither can drift from the other or from the assembler both
were generated from. Instruction lengths come from the shared shape table
too, so the cursor cannot get out of step with what was written.

THE WHOLE LINE IS UNDERSTOOD BEFORE ANYTHING IS WRITTEN. Emitting the opcode
first and discovering a missing operand afterwards leaves half an instruction
in memory, which the next line usually covers up and the last line of a
session does not. Written that way first and fixed.

What cannot be written is a label, and that is the whole difference between
this and the assembler proper: a label is a promise to fill an address in
later, and later is what a line at a time does not have.

The recorded test now types in a complete program - a string poked into Data
Memory, instructions assembled into Program Memory, and the result run - and
includes a lower case mnemonic, both selector forms, an instruction that does
not exist and one missing its value, so the refusals sit beside the successes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:29:50 -04:00
AnachronautandClaude Opus 5 5fd995aa62 Breakpoints: SWI osBreak, and s refuses a read only bank
A breakpoint that shows every register as the program had them, waits for a
key, and carries on.

NOTHING IS OVERWRITTEN, and that is the design rather than a shortcut. A
breakpoint poked into a running program has to replace an instruction, and
putting that instruction back in order to continue is the same act as
disarming the breakpoint; firing a second time would mean stepping over the
restored instruction and putting the breakpoint back behind it, and this
machine cannot step a single instruction. SWI is two bytes, dispatches
through a vector, and its frame already holds the address after it, so RETI
resumes at the next instruction with nothing to restore and nothing to
re-arm. It fires every time it is reached.

The price is that a breakpoint is part of the program: a build with them in
has different addresses from a build without. That is the bargain every
machine with a break instruction makes.

Every value shown comes out of the frame rather than the registers, because
by the time the handler runs the registers are the handler's. Apps/Break.asm
stops twice so that the second stop is checked as well as the first.

Also here, found by the test that came with it: the monitor's s wrote into
whichever bank was selected, and bank 2 is the controller's own table,
published read only. Writing to it was refused, and a refusal nobody catches
stops the machine - so selecting the bank table to look at it and then typing
s killed the session. bankPresent now keeps the whole flags byte and s
declines. The recorded output of cosmosMonitor had contained that crash,
having been blessed without being read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:09:21 -04:00