Commit Graph
64 Commits
Author SHA1 Message Date
AnachronautandClaude Opus 5 749fef8ce2 The shell's own words, as a table and not just a chain
The dispatch is a run of "is the line this name" comparisons. That is fine to
execute and impossible to WALK, and completing a half typed command needs to
walk them - so the names have to be data as well as code.

They nearly were already: DirName through ExitName were fourteen zero
terminated strings sitting back to back, which is a table by accident of layout.
This makes it deliberate. MonitorName joins them, the run is labelled, and a
count goes underneath because a run of strings does not say where it stops.

WHAT MAKES IT A TABLE IS THE ZEROES. Each name ends in one, so the next begins
after it: no pointers, no lengths, and adding a command costs a line.

Tests/docs.sh reads both the dispatch and the run and compares them, because the
two can disagree and every way they do is quiet. A command added to the dispatch
and not to the run simply never completes, which nobody would think to check by
hand. Something put BETWEEN the strings is worse: the walk ends there and takes
every command after it, and the machine goes on working perfectly except that
Tab knows about six things instead of fifteen.

All three break that way and say something useful. Putting one byte in the
middle of the run reports that it holds ten names against the fifteen claimed,
which points at roughly where.

Groundwork for Tab completion. Nothing uses it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 17:21:30 -04:00
AnachronautandClaude Opus 5 3449405b18 Give the system another page of each memory
CosmOS had 1,161 bytes of Program Memory left before the address applications
load at, and Tab completion is not going to fit in that with anything to spare.
So the wall moves up one page: the system keeps below 0x4FFF and 0x2FFF, and an
application is based at 0x5000 and 0x3000.

A PAGE IS A CHEAP THING TO GIVE IT AND AN EXPENSIVE THING TO RUN OUT OF. An
application still has 44K of Program Memory before the vector table and the
largest one here uses 7.5K, so what was taken from applications is space nothing
has ever asked for - while what the system gained is the difference between
building the next thing and counting bytes while building it.

Not doubling, which was the version that would have cost application space worth
minding. One page, and the same again when it is needed.

Nothing in the machine knows where the wall is, so this is 34 #Base lines, one
threshold in the fault handler, and the table in the CosmOS README that
Tests/docs.sh reads its limits out of.

The native assembler's scratch map had to move with it, and docs.sh said so
before anything ran: its data reached 0x40D6 and its buffers began at 0x4000, so
they were sitting on its variables. That file already carries a paragraph about
the floor coming up and the map staying where it was. It has happened twice now,
and been caught by a check the first time wrote.

Twenty three recordings are the same runs a page higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 16:49:12 -04:00
AnachronautandClaude Opus 5 f97d15de08 The font comes from a chip, not from RAM that remembers
videoReset zeroed video memory and then wrote the font and the sixteen colour
schemes into it, and the comment above that said out loud what was wrong with
it: "everything here is ordinary video memory". RAM does not wake up with
anything in it. That was the last piece of magic in this device, and it looked
harmless until something wanted the font BACK - a program that redefines a glyph
had destroyed the only copy there was.

So the device has a character generator, the way the machines this one is
pretending to be really did, and the copy into RAM is a thing it DOES rather
than a state it mysteriously starts in. Command port 0x39: bit 0 for the font,
bit 1 for the schemes.

THE RAM IS STILL RAM. A program may overwrite every glyph and every colour and
should be able to, which is what makes this a tile engine rather than a text
display. What changed is that it is no longer a one way door.

NEITHER COMMAND CLEARS WHAT IT DOES NOT OWN. The font used to clear the whole of
tile memory before writing itself, which was harmless while it happened only at
reset and is wrong the moment a program can ask: a program that defined a tile
of its own and then wanted its text back would have paid for it with the tile.

The reason it is a chip rather than a file on the disk, which was the other
candidate: the boot chain prints before CosmOS exists. Stage one prints "?" when
there is nothing to boot, and if the font came off the disk then the message
about the disk having failed would be the one thing that could not be drawn. A
system that wants its own font still loads one over the top - the ROM is the
floor, not the policy.

Two things that had been worked around now simply work. The shell asks for both
whenever a program exits, so a program that redefined a letter no longer leaves
it unable to spell, and Grid no longer needs to have saved the screen to avoid
handing back green text on blue. And the fault screen asks for the glyphs first,
because a message spelled in somebody's tile graphics is no message at all.

Five video checks. Two runs each for the font and the schemes, since the map
holds a tile NUMBER and the glyph is looked up when the frame is drawn - so
restoring changes every cell using it, including ones drawn before, and what the
two runs differ by is the command. The third guards the decision not to clear:
tile 200 has to survive the font coming back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 16:21:24 -04:00
AnachronautandClaude Opus 5 66be42d7bb A word the monitor does not know goes to the disk, not into the weeds
There was nothing at the end of the monitor's command list. An unrecognised word
fell off it and straight into sayPrompt - which is a ROUTINE, so its RET had
nothing of its own to return to and went wherever the Stack happened to be
pointing.

The user found it by typing a program's name at the monitor prompt, which is an
entirely reasonable thing to do: the monitor is a mode of the shell, so
everything the shell does is meant to work in it. What they got was a fault, and
before that a second prompt printed on top of the first - which is sayPrompt
doing exactly what it is for on its way past, and the tell that it had been
entered rather than called.

WHERE THAT RET WENT DECIDED HOW BAD IT WAS. Usually 0x0003, in the middle of
newLine, and the machine stopped on a byte that is not an instruction. Once it
was inside sbfsFormat, and the machine formatted the disk it had booted from -
the user's would not start again, and neither would mine, which is how I came to
have a reproduction before I had a diagnosis.

Pre-existing, and not recent: it is there at 2a29ceb and every revision I
checked back through.

The fix is one branch. cosmosMonitorRun covers all three cases the monitor now
has to handle - a program started by name, a program that faults, and a word
that is nothing at all - because the first of those is what the user did and the
last is what used to be fatal.

Worth naming as a shape: a run of tests falling through into a subroutine. The
symptom is not at the site, the failure depends on the Stack, and the damage is
whatever the return address happens to land on. SplitLint has no rule for it and
could have one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:30:04 -04:00
AnachronautandClaude Opus 5 fd9c4c75f8 Tell the person where it hurts
A fault stopped the machine and printed a line to standard error. On a terminal
that is a diagnosis. Behind a window it is a frozen picture and no reason at
all, because the message went somewhere nobody was looking - the machine looked
hung and was not. It had stopped, and said so invisibly.

CosmOS catches all five faults now and says what happened on the screen, with
the address, in red.

A FAULT ENDS THE PROGRAM, NOT THE MACHINE. That is the answer to "carry on or
start again", and it is not a compromise: a bare RETI from most of these meets
the instruction that failed and fails again, so carrying on was never on offer.
But the machine is almost never what is broken. Everything the shell puts back
when a program exits - the Stack, its vectors, the drive, the working directory,
the console, the screen - is exactly what wants putting back after one dies, so
the handler sets a status and joins handleExit. You are back at the prompt, and
the program is recorded as having STOPPED rather than finished, because saying
"finished" under a red fault message would be the shell contradicting itself.

A fault below where programs load is the system's own, and there is nothing to
go back to. That one says so and stops.

THE SCREEN GOES BACK TO A MODE TEXT CAN BE SEEN IN, and that is the part that
matters rather than the part that is prettiest. A program that faulted in bitmap
mode left the console with no text rows, so it draws nothing at all: the message
would be perfectly correct and completely invisible, which is the one thing it
must never be. Two palette entries go back for the same reason, since a program
that wrote its own colours can leave every ink the same as every paper. Only the
two the message needs, so the rest of what the program chose is left alone.

Both halves are checked by looking at the PICTURE, because the serial line was
never where the problem was. Crash blind ruins the palette and drops into bitmap
mode before it faults; without the mode the screen comes back 320 by 200 with
nothing on it, and without the palette it is the right size with the message
present and unreadable. Each break loses the red on its own.

Crash is also a program worth having: it breaks in whichever of the five ways
you name, so a fault screen can be looked at without having written a bug first.

Two things found on the way:

The native assembler keeps its OWN copy of the reserved vector names, so it did
not know NoHandler or NoDevice and built a cosmos.bin that differed from the
host assembler's. Caught by native.sh, which is exactly the drift that test
exists for.

And cosmosMonitor had dead input. It assembles code into 0x8000 and runs it, and
that code faults - which used to kill the machine, so everything after it in the
file had never run. It runs now, and the recording grew by sixty lines of
monitor session that had been unreachable since the day the fault was put there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:08:20 -04:00
AnachronautandClaude Opus 5 4d976fc22a A program reading a line gets the editing too
osReadLine goes through the shell's editor now, so anything that asks the system
for a line gets arrows, Home, End and Delete. The editor is a program, and a word
typed with two letters the wrong way round can be put right without starting the
line again.

IT DOES NOT GET THE HISTORY, and that is the interesting half. Edit would
otherwise fill the history with the text of somebody's document, and pressing Up
in the middle of writing one would put "dir" into it. The history belongs to the
thing whose lines are commands. Two entry points rather than a flag the caller
sets first, so a caller cannot forget which it wanted.

And the console is put back the way it was FOUND rather than the way the shell
likes it. A program that had asked for key mode and then read a line through the
system used to be handed back a console in line mode having asked for nothing of
the sort. The status port reports all three things the control port can ask for,
in the same order two bits along, so one shift turns what the console IS into
what to write to make it that again.

Which uncovered a real fault in the console. READING THE STATUS PORT WAS EATING A
KEY: in line mode the poll consumed an arrow key and dropped it, so a program
that looked and then asked for key mode - exactly what reading a line now does -
found the first key it was reaching for already gone. A look must not consume
what it cannot report, because the mode can change. It is held now and delivered
as soon as something will take it. A blocking read still discards it, and must:
that read IS the delivery, and a byte held there would be met again forever.

Four recordings gained a program's echo, and cosmosEdit's went from
"> : : : : > : : > 1: alpha" to a session you can read. VERIFIED THE SAME WAY AS
BEFORE: with only the program side of the echo silenced, all 192 tests pass
against the recordings as they were before this commit, so the echo is the whole
of what changed.

cosmosEditService is the new test and it checks both halves at once. Inside Edit,
Left/Delete/Left puts "alpah" right. Up and Down do nothing there - were a
program's line walking the shell's history, the next line would come out as the
echo command from the top of the file instead of the word. And one press of Up
back at the prompt finds the command typed before Edit was started, which is the
proof that nothing the editor read went into the history at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 14:07:09 -04:00
AnachronautandClaude Opus 5 71f6e215f9 The shell remembers what was typed before it
Up walks back through the last eight lines and Down forward again. It exists
only because the keys reach the system now: until A1 and A2 there was nothing to
press Up at, and the line was assembled somewhere the shell could not see.

A RING RATHER THAN A LIST. A ninth line pushes the oldest out by moving where
the ring starts, not by moving any of the lines - so keeping a line costs a copy
of that line and nothing else, however full the history is. Eight is a power of
two, so which slot an entry lives in is an AND. The ISA had the awkward part
already: A and B are a sixteen bit shift register, so one SHR with B empty turns
a slot number into the offset of a 128 byte slot, high byte and low, ready for
DPUW.

A NINTH SLOT HOLDS WHAT WAS BEING TYPED when Up left it, and Down brings it
back. Losing a half written line to a keypress is the sort of small rudeness
that makes a thing unpleasant to use, and it costs one slot to avoid.

An empty line is not kept, and neither is one the same as the line already at the
top. The test proves the second by looking one further back: if a repeated
command were kept twice, the line behind the newest would be the same line
again.

The redraw had to learn to rub out. One space was enough while the only thing
that shortened a line was taking one character out of it; a recalled line
replaces the whole of it, and a short line over a long one left the tail of the
long one on screen looking like part of what you were typing. It now covers
exactly what was lost - which turned out to be one space fewer than before in
the cases that GREW, so two lines of cosmosEditKeys lost a trailing space that
was never doing anything.

Costs 1157 bytes of Data Memory, taking CosmOS to 6220 of the 8192 it has before
a loaded program's data begins. Worth writing down: that is the budget, and this
is the largest single thing in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 12:24:23 -04:00
AnachronautandClaude Opus 5 81e544eb3d Load a program that has no data
A five instruction program that writes one port and exits has no Data Segment at
all, and the loader stopped the machine dead on it. It asked the memory
controller to move a segment of no bytes, and a length of zero asks for the
whole 64K - which is the machine's rule, and a reasonable one, since two bytes
cannot say 65536 and a transfer of nothing is not usually what anybody meant. It
is exactly what was meant here. 64K did not fit, the controller refused, and the
load stopped half done.

ON A TERMINAL THAT PRINTS A FAULT WITH AN ADDRESS. Behind a window it is a
frozen picture and no reason at all, which is how it was found and is a separate
problem from this one.

The header says how long each segment is, so the loader knows before it asks.
Both bytes are already in hand, so the test costs one OR. Nothing is lost by
skipping the transfer: a blit leaves the controller's addresses past whatever it
touched, and a blit of nothing would have left them where they already are,
which is where the vectors are read from next.

Guarded for the code segment too. A program with no code is equally assemblable
and would have stopped in exactly the same place.

Mode.sbx is the fix's test and a program worth having on its own: forty columns
or eighty, whichever the screen is not in, which is what a person wanting Snake
drawn twice the size actually needs. Ten instructions and no data, deliberately
- it prints its two digits a register at a time rather than from a string, so it
stays the smallest shape a loadable program can take. Nothing else on that disk
had ever been that shape, which is why nothing had ever tried it.

Reported by the user, who wrote the program.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 11:47:20 -04:00
AnachronautandClaude Opus 5 736037462e The shell edits the line it is given
Three different things used to do this job, and which one you got depended on
where the machine was running. On a terminal the host held the line and did the
echoing and the backspacing; behind a window the console's own gatherer did it;
from a file nothing did it at all. One job, three implementations, none of them
in the system - which is why there was no way to move about in a line and
nowhere for a history to live.

So editLine does it. Key mode while a line is being read and line mode straight
after, so nothing else in the system and no program calling osReadLine notices
anything changed. Left and Right, Home and End, Backspace for the character
before the cursor and Delete for the one under it, and anything typed goes in
where the cursor is with the rest of the line moving along.

Ctrl-D means the end of input again, on an empty line, because that was a thing
the terminal did while it was holding the line and it is not holding it now.
Same trade as the echoing.

MOST KEYSTROKES DRAW NOTHING BUT THEMSELVES. A character typed at the end of a
line needs no cursor moved: printing it is the whole change, and a backspace
there is three ordinary bytes. That matters beyond speed - moving the cursor by
hand is what a terminal is TOLD about, in an escape sequence, so redrawing on
every keypress would fill every recorded transcript in this suite with them.
The line is only reprinted when something happened in the middle of it.

Where the line STARTS is worked out backwards from where printing ended, rather
than trusted from what was remembered. That is what makes it survive the screen
scrolling: a line printed on the bottom row moves everything up by one, and a
remembered row would be one too low from then on.

The command line holds 127 characters, up from 63. The limit started to be felt
the moment a line could be moved about in.

58 recordings changed, and every one of them by the echo. THE PROOF IS NOT A
HEURISTIC: a CosmOS built with the echo silenced reproduces 187 of the 188
recordings byte for byte. The one exception is cosmosTyped, the backspace test,
where the rub-out marks now come from the shell instead of from the console's
gatherer - same marks, different author.

cosmosEditKeys is the new test, and every line in it is typed wrong and then
corrected with a different key. Its last line is eighty six characters at a
prompt in column two on an eighty column screen, so the line runs onto the row
below and the shell has to find the start of something it can no longer see;
breaking either half of that arithmetic fails it.

Also: agree.sh looked for "> the same", anchored to a prompt that no longer
precedes what a command prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 23:01:55 -04:00
AnachronautandClaude Opus 5 2a29cebc6b Make the screen come back on the machine people actually run
Found by running it: Grid exits and the shell prints its prompt into the
grid, with the view up to seven pixels out of alignment. Three faults, and
the first is the one that made the other two visible.

MAKE RUN-VOYAGER HAD NO SCRATCH DRIVE. It gives drive 1 to Disks/personal.img,
which is a file and not volatile, so there was nowhere to keep a screen -
osTakeScreen answered no and the whole feature silently did not happen. It
was tested with --ram-disk and shipped without one, which is as good a
description of testing the wrong machine as I can write. There is now a RAM
disk in drive 2, after the personal disk so that drive 1 stays the one that
is yours.

A PROGRAM TOLD NO MUST COPE. A refusal is not a fault, it means doing what
the program did before there was anywhere to save a screen. Grid deleted
its own tidying up when it started asking, so being refused left the grid
on screen with a prompt written into it. It clears up again when refused,
and only then.

AND THE SYSTEM ALWAYS LEAVES THE SCREEN USABLE. The fine scroll registers
go back to zero at every program exit, whether or not the picture could be
saved: the console draws in whole cells, so a view three pixels into one
puts every character three pixels out for ever. That is not part of saving
a screen and should never have depended on it.

Both paths are checked now. With a scratch drive the screen comes back cell
for cell; without one, no grid is left behind. Breaking either fails one of
them and not the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 20:31:26 -04:00
AnachronautandClaude Opus 5 ab72443b99 Give the screen back: osTakeScreen, and the RAM disk earns its keep
A program that takes the whole screen leaves the shell a blank one, and
whatever was on it is gone. There was nowhere to put 48K of video memory on
a machine with 64K of Data Memory that CosmOS already lives in.

A DRIVE MADE OF MEMORY IS SOMEWHERE. The screen goes to a file on the
scratch drive - the first volatile drive found at boot - like any other
file, and comes back from handleExit alongside the vectors and console mode
already put back there. The filesystem does the allocating, so this
invented nothing: it is 196 pages of tiles, map and palette, with a block
on the front holding the cursor, the four scroll registers and the mode.

NOT AUTOMATIC, and that is the whole design. Saving on every program start
would be cheap enough; restoring on every exit would be wrong, because dir
and Files and Say print and stop and their output is the reason you ran
them. A program says it took the screen, and one that says nothing behaves
exactly as every program did before this existed.

It deleted thirty lines of Grid, and they were all wrong anyway: four
scroll registers put back by hand, the map filled with spaces, the cursor
sent home, palette bank 0 written out - and the other fifteen banks kept
Grid's colours, because there was nowhere to have kept the real ones. Grid
is 64 bytes smaller and gives back what was actually there.

The check compares the screen before against the screen after, CELL BY
CELL, and allows only the rows around the cursor to differ - found from
where the text ends rather than guessed at, because the first version
assumed the cursor was near the bottom of the screen and let three real
differences through.

Two things cost time and neither was the feature:

  - An edit adding "SWI osTakeScreen" to Grid was in the same script as a
    failing s.index, so the file was never written - and the COMMENT
    describing the call did land, from a later edit. Grid documented a call
    it did not make, and read as though it should have worked.
  - docs.sh caught osTakeScreen having no row in the services table, which
    is the check the service layer added for exactly this and the second
    time it has earned itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 18:47:44 -04:00
AnachronautandClaude Opus 5 04f1ffabd4 A disk made of memory, brought up by whoever owns it
THE MACHINE SUPPLIES BLOCKS AND SAYS WHAT A DRIVE IS. It says nothing about
filesystems, which is what leaves room for a system that would rather have
its own - and is why the volatile bit is a fact about the hardware rather
than a promise about SBFS.

  0x26        what the selected drive is: bit 0, contents do not survive
  0x27, 0x28  how many blocks it has
  --ram-disk N   a drive of N blocks with memory behind it

A drive of memory selects, reads, writes and has a size like any other, and
a program cannot tell the difference except by how fast it was. The one
thing it cannot work out for itself is that the contents are volatile,
because an empty disk and a volatile disk look identical from outside.

THAT BIT IS THE DIFFERENCE BETWEEN A DRIVE A SYSTEM MAY FORMAT ON SIGHT AND
ONE IT MUST NOT. CosmOS formats a volatile drive it cannot read, because
there was never anything on it to lose, and leaves every other unreadable
drive alone - an unformatted floppy is not an invitation, it is a blank
floppy. Removing that check formats somebody's blank disk, which is checked
rather than asserted: cosmosBlankDisk boots with one and requires it to be
refused.

So CosmOS grew a format. The size comes from the drive rather than from a
superblock, since a superblock states a size too and that is no use on a
disk which has not got one yet. Sixteen directory blocks, 128 names, chosen
rather than worked out: a scratch disk runs out of names long before room,
and this machine cannot divide.

The RAM disk is no faster on this emulator by default, and that is honest
rather than disappointing: the emulated disk has no seek time unless asked
for one. With --disk-cycles 10000 the same copy is 7.94M cycles against
8.70M, the difference being every write.

run.sh takes "ram:2048" where an image name goes, which needs no removing
between runs because there is nothing to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 17:56:41 -04:00
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 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 a12d61fb80 Give the shell an echo, so a script can say what it is doing
Say.sbx has printed words since long before there were scripts, and is the
wrong shape for one. It is a program: it has to be found on the disk,
loaded and started, it prefixes what it was told with "it says:", and the
system prints "finished" after it. Three lines of noise around one line of
narration, and a load off the disk to produce them.

echo is a command, so it costs a comparison. With nothing after it, a blank
line - which is what anybody expects and is worth having to space a long
script out.

Not called "say", although that is the better word. Built-in commands are
tried before the disk and always win, so a built-in say would shadow Say.sbx
and quietly change what every existing script and test meant by it.

Also puts "do" in the help, which the commit that added it forgot, and
splits the help text: the new lines pushed it over the assembler's 255
character limit for one string. That failure was hidden for a few minutes
by a 'make' whose errors were going to /dev/null - the build kept the disk
it already had, and the machine cheerfully reported "I do not know: echo"
from a system assembled before echo existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 16:07:44 -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 fc56e815fc Have the shell remember whether a line worked
Groundwork for scripts, and invisible until there is something to read it:
the suite passes unchanged, which is the point of doing it on its own.

A script has to decide whether to run the next line, and nothing in the
shell knew whether the last one worked. LineFailed is cleared as each line
is read and set by the fourteen paths that fail.

Cleared at the start rather than set at the end, because there are thirty
seven ways back to the prompt and only fourteen are failures - and the
twenty three successes would have to be found again every time a command
grew a new way to finish. A command that says nothing worked. Twelve of the
fourteen already funnelled through fileComplain, so this is fourteen lines
rather than the refactor it looked like.

It is deliberately NOT LastStatus, which was the obvious place and is
wrong. That one is a program's own answer, reported by the status command
and recorded by two tests; clearing it as each line began wiped the answer
before the command that reports it could read its own line. The tests said
so immediately. Two questions, two bytes - and a program exiting non-zero
now sets both, because a program answering "no" is one of the ways a line
can fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 15:38:02 -04:00
AnachronautandClaude Opus 5 5732a31b2e Stop the allocator starting the directory again for every file in its way
Placing one file on a disk with the source tree on it cost 9.4 million cycles. It costs 1.4
million now, and assembling colours.asm went from 13.9 to 6.0 seconds.

sbfsAllocate gave up the moment it found anything in the candidate's way: it moved the
candidate past that one entry and STARTED THE DIRECTORY AGAIN FROM THE FIRST BLOCK. With
files laid down one after another that is a restart per file, and every restart reads
directory blocks off the disk until it reaches the next thing in the way - which is further
in each time. Placing one file among 183 of them cost thousands of block reads.

The candidate moves along DURING the pass now, and the pass carries on from where it is, so
entries later in the directory are tested against where the candidate has got to. On a disk
that has been appended to - which is what a disk mostly is - one pass walks it past
everything and a second confirms nothing is left. Two passes rather than one per file.

IT IS STILL FIRST FIT, and Tests/agree.sh is what says so: the machine and SplitDisk build
the same tree and the images still match byte for byte, which they could not if allocation
had started choosing differently. The argument is that the candidate only ever moves past
something that genuinely overlaps it, and when it does there is nowhere below to go - the
entry in the way covers everything up to its end and begins before the candidate ends.

The first attempt at this was slower than what it replaced, by three times. It finished the
pass and jumped to the FURTHEST overlap, which sounds better and is worse: with files laid
contiguously only one entry ever overlaps, so the old early exit was the fast path and
reading the whole directory to find the one thing was pure loss. The number of passes was
never the thing to fix - restarting them was.

The boot slot in the test fixtures goes from 32 blocks to 40, which is what a shipped disk
has. Stage two is 8,231 bytes and 32 blocks is 8,192: a fixture tighter than the thing it
stands in for fails on a change the real disk would have taken, and says "the boot slot is
too small" rather than what actually grew.

WHAT THIS DOES NOT FIX is assembling CosmOS, and that is worth saying plainly. It takes 654
million cycles on the mirrored disk and 653 million on a flat test disk with a sixth as many
files, so it is not a filesystem problem at all. Cycles per byte of source climb with the
size of it - 1,383 for colours.asm, about 3,000 for Edit.asm, 6,290 for cosmos.asm - which
says the native assembler is superlinear in what it reads. That is a separate thing to go
and look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 09:54:29 -04:00
AnachronautandClaude Opus 5 d6feddd1b6 Give the console colour and a cursor
COLOUR COSTS A NIBBLE AND NO HARDWARE. A glyph is drawn in palette indices 0 and 1, paper
and ink, and a cell's attribute nibble adds sixteen to both - so sixteen banks is already
sixteen ink and paper pairs, and all that was missing was a register saying which one the
console draws in. That is port 0x06, read as well as written like the rest.

The palette a machine wakes up with is arranged so that HIGHLIGHTING IS ONE BIT: banks 0 to
7 are colours on black, banks 8 to 15 are the same colours as paper with black ink. So
attribute XOR 8 turns any pair inside out. That is a convention rather than a rule of the
machine - the device only ever adds the nibble and looks the answer up - but it is the
convention that makes a highlighted line and a cursor free.

Bank 0 is still grey on black, so nothing that was written before this has changed colour.

THE CURSOR IS THE SAME BIT AGAIN. It is drawn by turning its cell inside out rather than by
putting a block over it, so the character underneath stays readable, which matters to
somebody editing a line. The device draws it rather than the window, because on a machine
with a screen a cursor is a hardware feature - one drawn by the presenter would not be in a
picture the machine saved.

It blinks on the machine's own clock, half a second each way, so the phase is a pure
function of the cycle count and a screen saved at a given cycle is the same screen every
time. A blink on the host's clock would have made every saved picture a matter of luck.

Off unless asked for, with bit 2 of the control port. That is right for a machine - a
program painting its own screen does not want something blinking in the middle of it - and
CosmOS asks for one at boot. It also asks again when it takes the console back from a
program that has stopped, because a program handing key mode back the way it was told to
writes zero, which turns the cursor off. The shell owns the prompt, so the shell is what
makes sure there is something blinking at it.

Nine more checks in Tests/video.sh, to 41: that the attribute colours the ink and not the
paper, that XOR 8 turns both, that it reads back, that a cursor appears where the registers
put it and only when asked for, and that it goes dark again half a million cycles later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 08:13:28 -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
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 f1cc2e56b2 The last handler that knew a frame offset
handleReadLine used DP1 for the frame and the conversion only looked for
DP2, so it kept the whole dance - and the dance had become a no-op that
looked like work: MVQA copied Q into A, A was written over the saved Q, and
RETI restored it, which is where it started. readLine leaves the length in
Q and SRET keeps Q, so the answer simply stands.

No handler in CosmOS knows what an interrupt frame looks like now. The
0d02 and 0d05 offsets still in the file are structures - an SBEX header, an
entry in the instruction table - and not frames.
2026-08-27 18:30:28 -04:00
Anachronaut c8c9f0b363 SRET: a handler answers the way a subroutine does
CALL saves A, B and Data Pointers 0 to 2 and nothing else, which is exactly
why Q and DP3 are how a subroutine hands something back. An interrupt saves
all of it, so a service with an answer had to reach into its own frame and
un-save two fields by hand:

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

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

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

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

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

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

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

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

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

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

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

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

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

The monitor's recorded disassembly moved by exactly the bytes it should:
18 became 72 wherever SWI appears, with SETD and INIB untouched and every
disassembled line still reading the same.
2026-08-27 18:05:54 -04:00
Anachronaut 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 9c144469b4 Take the SplitLint findings that are one operation, leave the rest
Twenty four more sites, and the interesting part is which ones were left
alone. A rule emerged while reading them and it held all the way through:
apply where the repetition is INSIDE one operation, skip where the author's
own structure says it is a new thought, and never where two equal values
mean different things.

Taken:

- Five registers reassigned to a value they already held, where both are
  the same quantity: two masks in one expression in Snake, two spaces
  printed by the monitor, both halves of block zero in waitTest, and a RSTA
  in Pour that the very next instruction overwrote.
- Eighteen SETDs that reload a pointer inside one operation - a store back
  into the variable just read, or an INCD stepping to the second byte of a
  two byte value. Those read correctly without the reload.
- sbfsNext, which branched to the label on the line below it.

Left, with reasons that are the useful part of this:

- Eight registers where the same number means two different things. CosmOS
  and the loader set A to 1 for a blit command and then to 1 again for a
  bank number; Asm compares a type against 3 and then a status against 3.
  Removing those couples one quantity to another that is equal by accident
  and would part company silently.
- Ten RSTAs that open the RSTA/RSTB/CCF/ADD "return zero" block. The
  redundancy is what makes that idiom self contained; taking it out makes
  the return value depend on the line above.
- Eleven SETDs that begin an arm of a comparison chain. Each arm loads,
  compares and branches, and they get reordered - the repetition is the
  reason a new arm can be dropped in anywhere.
- Twenty five SETDs separated from their pointer by a blank line or a
  comment, which is the author saying a new thought starts here.
- Two CCFs before arithmetic, which this codebase writes unconditionally.
- Three redundant branches in test programs whose recorded output includes
  addresses, where three fewer bytes moves what the test demonstrates.

Nine recorded outputs moved and every one is a size in a listing or, for
Life, five more generations inside the same cycle budget. Behaviour is
unchanged everywhere: cosmosSnake and cosmosEdit pass byte for byte while
Snake loses eight bytes and Edit twelve.

CosmOS is 10,902 bytes of program against 10,937, and the native assembler
12,173 against 12,183. The CosmOS README's size for Edit moved twice in one
sitting, and this morning's check caught it both times - which it could not
have done before that claim was reworded to name what it was about.
2026-08-26 18:02:35 -04:00
Anachronaut e1273337c4 Two mechanical fixes SplitLint found: MVQA, and RSTA for zero
Twenty four places moved Q into A or B by pushing it and popping it back.
That is four bus cycles and two bytes to do what MVQA does in one of each,
and several of them are inside loops - Life, the calculator, int8. Nineteen
more loaded zero with INIA 0d0 where RSTA says the same thing in one byte.

Both are equivalent at the CPU rather than by assertion: RSTA and INIA both
leave Status alone, and PSHQ followed by POPA nets to A = Q with the Stack
Pointer where it started. The one difference is that the pair leaves a copy
of Q in memory just below the Stack Pointer and MVQA does not, which
nothing here reads.

Five recorded outputs moved and every one of them says the change worked:

- 16x16Life fits five more generations into the same cycle budget, the
  first 457 lines identical, because the loop got cheaper.
- Life.sbx is 1409 bytes rather than 1411, in three tests that list it.
- Edit.sbx is 1995 rather than 1996.

That last one broke a check I added this morning, and the hole is worth
recording: the CosmOS README's claim about Edit's size did not have the
word "Edit" on the same line as the number, because the subject was in the
sentence before, so the check that measures quoted sizes skipped it
silently. The sentence now names what it is talking about, which makes it
both checkable and clearer, and the check fails on a wrong number there.

Comments on either half of a replaced pair are carried onto the
instruction that replaces them, so nothing anybody wrote was lost.
2026-08-26 17:53:10 -04:00
Anachronaut c216c83e12 The filesystem waits for the disk instead of asking it repeatedly
sbfsWaitDisk spun on the status port. Read ahead had already hidden about
three quarters of the latency, and what remained was still 11.5% of Type
over a 14K file on a ten thousand cycle disk - all of it memory traffic
spent finding out that nothing had happened yet.

It tests the port first and only waits if the disk is still busy, and that
order is the whole of what makes it safe: the disk raises its line when it
finishes, so a disk that finished in the gap between the test and the WAIT
has its line standing already and the WAIT does nothing rather than
sleeping through the answer. No handler and no vector - the shell keeps the
Interrupt Flag down, and a WAIT wakes on a line whether or not anybody
means to answer it.

Printing a 14K file, by where the cycles go:

  cycles a block     total       bus   waiting
       0           922,570   922,570         0
   2,000           946,474   922,702    23,772
  10,000         1,042,474   922,702   119,772

The bus column stops moving. What the program costs in memory is now the
same whatever the disk does. On this emulator nothing observable changes;
on hardware it is a CPU standing out of the way of the memory controller
rather than competing with it for every one of those 119,772 cycles.

The first version cost 660 cycles more at latency zero because it read the
status port again on the way out. AND writes to Q and leaves A alone, so
the status was already there - which is what the original said in its own
comment, and what I stopped believing while rewriting around it.
2026-08-26 11:14:17 -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
Anachronaut ce8fb721fe Say a temporary is one in the entry, not in its name
Saving something that already exists writes a temporary, deletes the
original and gives the temporary its name, so that nothing is lost if the
writing fails. The temporary was told apart from a real file by being
called sbfs.part or sbfs.out - and those are legal names. Starting a save
deleted whatever answered to one as stale scratch, so saving anything at
all in a directory destroyed your own file of that name there, silently.

Flag bit 0x04 now says it. The property is not in the contents - the same
bytes become the finished file the instant the rename lands - so it belongs
in the entry, which is the thing the commit changes. sbfsCreateTempAt is
the door temporaries come in by, the commit writes the flags flat along
with the name, and cleanup wipes what it finds only if the entry says it is
ours. Anything else stops the save instead.

The bit is also the recovery. Both listings show an unfinished write rather
than sizing it, because the size in the entry is the room that was asked
for and not what was written: "<unfinished>" from dir, and a line from
SplitDisk saying the blocks are held and a rename brings the data back.
That was the gap in what the last commit documented - the data survived a
crash and nothing would show you where it was.

Four new agreement checks, three of which fail with the guards removed. The
fourth needed rebuilding first: both tests started on one disk, and the
first save ate the sbfs.part that was the second test's SOURCE, so the copy
failed for want of a file, never opened a stream, and passed while
reporting on nothing. A disk each. The fifth check forges the wreckage by
setting the flag on a finished file, since nothing here can crash a save
half way through.

No version bump: a committed file never carries the bit, so a disk this
writes is byte for byte the disk the old code wrote, which the whole-image
comparisons confirm. Only the wreckage differs, and older code reads that
as an ordinary file - which is what it did before.
2026-08-25 23:22:37 -04:00
Anachronaut 9e2aa0122e Say what a safe save actually promises, and where it stops
Both sbfsSaveFile and sbfsStreamDone write a temporary, delete the original
and rename the temporary onto its name, and the README explained why that
order and not the obvious one. It never said what the resulting guarantee
is, which invited the stronger reading: "safe save" sounds like it survives
anything.

It survives everything that goes wrong while it is running - no run long
enough, no free blocks, a refused write, a name that turns out to be a
directory, a writer that gives up - and in all of those the original is
untouched. It does not survive the machine stopping, because the commit is
two block writes with a gap between them: stop there and the old file is
gone and the new one is still called sbfs.part. The bytes are all present
and one rename by hand recovers them, but nothing does that on its own.

Written down in the README beside the ordering it qualifies, and at both
commit points in sbfs.asm, where a reader is when the question occurs to
them. Closing the gap wants a journal or a second directory, which is a lot
of disk for two writes; a boot-time consistency check is the cheaper answer
if it ever matters, and it would want temporaries flagged in the entry
rather than recognised by name.
2026-08-25 22:56:07 -04:00
Anachronaut aa7bdc6acd Check the memory map against itself, not only against the code
The CosmOS README's Data row gave the system 0x0000-0x3FFF and a loaded
application 0x2000 and above: two columns of one row that cannot both be
true. Program was doubled to 0x3FFF when CosmOS outgrew its first map and
that number was copied into the Data row as well, where the answer is
0x1FFF.

docs.sh measured both segments against the CosmOS column and passed the
table anyway, because it never read the column beside it. A number checked
against the code and not against the number next to it is still unchecked,
so it now reads both and compares them - and compares two further copies of
the same fact that had gone stale on their own: the minimal application in
the README, still based where applications lived before the doubling, and
the map cosmos.asm opens with, which somebody reading the system reads
before they read the README.

Each of the three checks was confirmed by breaking the fact and watching it
fail; the first reproduces exactly the text this commit removes. While in
that header, the command list said five commands and CosmOS has eleven and
a search path besides, and "dump is next" outlived the monitor.
2026-08-25 22:54:14 -04:00
AnachronautandClaude Opus 5 db3d349da8 Read the next block while the program is busy with this one
A file is read front to back, so when something asks for a block, the one after
it is almost certainly wanted next. sbfsReadOne asks the disk for it straight
away and hands back the block that was wanted - so the transfer happens while
the caller is busy, and the waiting is mostly over by the time it comes back.

Printing a fourteen kilobyte file: 1,064,498 cycles at two thousand a block
becomes 976,882, and 1,576,562 at ten thousand becomes 1,032,889. The second
figure barely moves between those - seven per cent from an instant disk to a
slow one, where without it the same change costs sixty eight. A machine that
reads ahead stops caring very much how fast its disk is.

NOT FOR DIRECTORY SEARCHES, and that was tried first and thrown away. A scan
stops the moment it matches, so the block it would read ahead is one nobody
will ever look at - a transfer to fetch and another wait to throw away. It was
nineteen per cent SLOWER on a lookup at ten thousand cycles a block. Reading
ahead is a bet that the next block is wanted, and a search is exactly the case
that hopes it is not. The scan loop is untouched.

Three per cent is what it costs when the disk is instant, which is the
bookkeeping with nothing to hide behind it, and the default.

The disk has one buffer, so the only way to know what is in it is to remember
what was last asked for. Every read records that; a write clears it, because a
write fills the buffer from memory and no read asked for what is in it. Getting
that wrong would blit whatever happened to be there and call it the block
somebody wanted.

The read ahead is bounded by the file's own length, so reading the last block
does not fetch whatever follows the file on the disk - which belongs to somebody
else and would be paid for twice, once to fetch and once to discard.

It cost one bug, and an instructive one: the next index was worked out by
stepping SbfsIndex, which is the CALLER'S and is still wanted after the return -
handleFileBlock compares it against the file's length to see whether this was the
short last block. Every block reported the wrong number of bytes, and the output
of Type skipped five lines in the middle. It has a place of its own now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 22:36:22 -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 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 fb7b224bbb S2: the assembler writes the file as it makes it
The output image is gone. It was eighteen kilobytes and it is now one block of
window, because the file was always produced in order and only ever needed to
be written that way.

Everything works in FILE OFFSETS now. A cursor is a two byte number counting
from the front of the file, and since a block is two hundred and fifty six
bytes, the block it lands in is the offset's high byte and the place within that
block is its low one - so there is no division anywhere, and ImgWalk, ProgPut
and DataPut needed no change but where they start.

ONE WINDOW RATHER THAN THREE. The plan said three: one per segment, and a third
for the block where the program ends and the data begins, which belongs to both.
Fetching a block back instead makes all of that one case. The header is patched
after every byte is out, the boundary block is written by both cursors, and both
are simply revisits - a revisit is what fetching handles. osFileFetch is the
service that allows it, and is the read side of the write.

A run of bytes in one segment costs nothing extra; a switch between segments
costs two block operations, and a source file has a few dozen switches and
several thousand bytes.

Two bugs, both a pointer meaning two things:

putAt took the cursor to advance in DP2 and then wanted DP2 for the window's
address. A call puts DP2 back the way it was AT THE CALL, so the step at the end
moved whatever the last call had left there - the window walked off across
memory while the cursor stood still. It goes in memory now, like the block did
in S1, and for the same reason.

The size the file is created at could not be right. How many vectors are
actually installed is not known until the second pass has resolved their
handlers, and by then the file must already exist to be written into - so Keys,
which brings one vector, came out four bytes short. Teaching the first pass to
count them meant teaching it about devices, and about a Boot line in a loadable
program not being installed at all, which is two ways to disagree with the
second pass about what a file contains.

So osFileDone is told the size instead. A writer asks for as much as the file
could possibly come to - the whole of it plus four bytes for every vector
DECLARED, which no file can exceed - and says what it really came to at the end.
The blocks it did not use go back to the free count. Asking for too much costs a
moment; asking for too little writes off the end of a file.

That is a better service for it, not a workaround. A writer that cannot know its
size until the last byte is the ordinary case, and it is exactly the case this
whole rung exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 16:58:17 -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 06bdbf7728 D5: move in, and give the assembler somewhere to look
The demo disk is three directories instead of thirty nine names in one list
with cosmos.asm sitting between fileStream.asm and sbfs.asm:

  /Apps    what you run
  /Source  what you name to the assembler
  /Lib     what those include

The split is by ROLE rather than by which directory the host keeps a file in.
Everything in /Lib is named by an #Include somewhere and by nothing else, which
is what makes it a library rather than a source.

THAT LAYOUT WAS NOT POSSIBLE UNTIL NOW, and finding out why is what this rung
actually cost. An include on the machine was a bare name resolved where you
stood, so every source that calls a service had to sit in the same directory as
services.asm - which is every source worth having. The first arrangement of
this disk put the examples in a directory of their own and none of them would
assemble.

So the native assembler has a search path: beside you, then /Lib. The same rule
the shell already uses for a program it does not recognise, applied to the
thing that reads source, and the same reasoning for it being two fixed places
rather than a list - a list needs somewhere to live between one boot and the
next, and there is no such place yet. It also brings the native assembler
nearer the host one, which has searched -I directories since before there was a
machine to run this on.

The reader's per-file state grew from 293 bytes to 301, because the name it
keeps is a path now and every block of a file is asked for by it. Six of those
would no longer fit the room set aside, so the include list moved up a page.
Both numbers are written down in two places on purpose and both were changed.

dir said cosmos.asm was 17,460 bytes. It is 82,996. The size came out of the
block count's LOW BYTE shifted up and the tail beneath it, which is sixteen
bits, so anything from 256 blocks upward came back as itself less 65,536 - a
plausible number, and wrong. Files that big say their size in blocks now.
Printing the true figure wants decimal printing twenty four bits wide, which is
a page of console.asm to say something nobody reads more precisely than "big".

The Assembler Manual's line about SBFS being flat was the last thing in the
repository still claiming it, and docs.sh now looks for that phrase and three
like it in all four documents. Not a section that is wrong - one clause inside
a paragraph that is otherwise right, which is the shape this kind of staleness
takes.

The duplicate puts are gone with the wildcard that caused them, so building the
disk is quiet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 10:04:12 -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