Commit Graph
32 Commits
Author SHA1 Message Date
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
AnachronautandClaude Opus 5 dbe58db660 Add Type and More, and the file stream they are built on
Two applications that read a file too big for Data Memory: Type prints one,
More pages it. Both sit on fileStream.asm, which wraps osFileInfo and
osFileBlock into open-and-next so an application walks a file's blocks without
repeating the service calls.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 17:15:53 -04:00
AnachronautandClaude Opus 5 4fd8bf7b3f Step a Data Pointer with INCD and DECD, not DPUP and DPDN by one
DPUP takes an immediate, so an offset of one is legal and does exactly the
right thing. It is also three bytes where INCD is two, and reads as "offset
the pointer up by one" where INCD reads as "step the pointer".

56 of them across 15 files: the system, the assembler, the editor, and eight
test programs. CosmOS is 9,564 bytes to 9,537, the native assembler 11,648
to 11,635, and every program in the repository together 49 bytes lighter.

The worst offender was numbers.asm, written this week, where every sixteen
bit helper reaches the low byte and comes back the long way round. It is the
file every other part of the assembler includes, so it is the first thing
anybody reads when they go looking - and it was teaching them the long way.
Pattern matched off sbfs.asm rather than off the instruction table I had
just embedded in two programs.

THIS IS NOT TWO WAYS TO DO ONE THING. DPUP takes an arbitrary number, so one
is inevitably among them; INCD earns its place by making the common case a
byte cheaper. The overlap is structural and the choice is a usage question,
which is a linter's job rather than an ISA's - "DPUP.n 0d01: INCD.n does
this in a byte less" is a mechanical rule with no judgement in it.

Nothing needed re-recording, which was not a foregone conclusion: cosmosBreak
prints the system addresses the registers happened to hold, and they did not
move. Both assemblers still produce identical bytes and CosmOS still builds
itself to a fixed point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 18:16:27 -04:00
AnachronautandClaude Opus 5 3d2ab34229 Streaming: read a file bigger than the machine's memory
osFileRead hands over a whole file, which settles anything under 64K and
settles nothing above it. CosmOS's own source is above it - the sources
together are 104K against 64K of Data Memory - so a machine that is going
to assemble itself needs another way to ask.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:29:50 -04:00
AnachronautandClaude Opus 5 c23adb2836 Break: name the status register properly and show the Stack Pointer
The dump labelled the status register "S", which reads as Stack to anybody
sensible - and the Stack Pointer was the one register it did not show, so
there was nothing to contradict the guess. It is written "status" now, and
followed by the bits that are up, because a dump that makes you look the
number up is only half a dump.

The Stack Pointer is not in the frame, since the frame is where the Stack
Pointer is. What the program had is fourteen bytes above it, that being what
entering an interrupt puts down, so it is worked out and shown.

Apps/Break.asm takes its second stop inside a subroutine, so the recorded
output shows the Stack Pointer at FFFF and then at FFF5: a difference of ten,
which is the size of a CALL frame. That checks the value is derived rather
than constant, which the previous version could not have told you.

Reported by Anachronaut, who read the output and asked why a pointer was two
digits long.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:09:21 -04:00
AnachronautandClaude Opus 5 0b6d2be43f CosmOS: a service interface for the disk and console, and the monitor in the shell
Two changes that arrived together because both live in cosmos.asm.

THE SERVICES. A loaded program that wanted a file had to include the whole
filesystem, carrying two and a half kilobytes of a private copy of code the
system already had running, and then mount a disk that was already mounted.
Five services are added at pinned numbers 20 to 24: osFileRead, osFileSave,
osFileDelete, osFileRename and osPrintNumber.

The sizes fit the registers exactly in both directions. A file that can be
read into Data Memory is under 64K by definition, so its length is sixteen
bits: coming back it is DP3, going out it is A and B together, and neither
direction needs a record in memory whose shape both sides must agree on.

There is deliberately no service to mount a disk. The system mounts one
before its first prompt, and a program mounting it again was only ever a
consequence of owning a second copy of the library, so that call disappears
rather than moving. Apps/Files.asm writes, reads, renames and deletes a file
in 645 bytes and includes nothing but the service names.

THE MONITOR. Previously an application, now part of the shell, because an
application occupies the one region a loaded application is given: a monitor
that was an application could never examine another one, since loading the
thing to be inspected would replace the thing doing the inspecting.

"monitor" turns it on and the prompt becomes "*". It is a mode rather than a
sub-prompt, and it persists: because the mode is a variable the prompt reads
rather than a second loop, and every path back to the prompt goes through one
place including osExit, a program started with "g" that gives the machine back
arrives at the monitor prompt it was started from. Examining a program and
running it therefore do not interrupt each other. "exit" leaves whatever you
are in.

It supersedes dump, and adds disassembly, writing bytes, and jumping to an
address. Its instruction table is generated from the assembler's own list by
Tests/instructiontable.py rather than typed again, and Tests/docs.sh checks
both that the system's copy matches the generator and that the lengths that
table implies are the ones the manual's Bytes column prints. A disassembler
that disagreed about a length would not print one line wrong, it would lose
its place and print everything after it wrong.

Also here: b refuses a bank that is not registered, since asking the
controller for one is refused and a refusal nobody catches stops the machine;
g records the Stack the way run does, without which a program returning
through osExit restored whatever the last run had left; and make cosmos-disk
now depends on the system as well as the image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:19:15 -04:00
AnachronautandClaude Opus 5 301716e869 sbfs: give sbfsMatchEntry a counter of its own
sbfsFind counts a block's eight entries in SbfsCount and calls
sbfsMatchEntry for each one, and sbfsMatchEntry used the same variable for
its own twenty two byte comparison. A name differing at its first byte
returned without decrementing, leaving 22 in the entry counter, so the
search then walked twenty two entries through a buffer holding eight: off
the end of it, into whatever data happened to follow, matching against
rubbish and reporting a file that is not there as found.

It had always been there. What decided whether it looked like it worked
was the bytes after SbfsBuffer, so it stayed hidden until unrelated data
was added and changed them. It showed up as "dir" followed by any failed
"load" reporting that the file could not be read rather than that it does
not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:18:12 -04:00
Anachronaut c4b59acc68 Infrastructure for system services through software interrupts. 2026-08-19 15:16:30 -04:00
Anachronaut e3100b4718 Fixed assembler bug that caused crash on IR array resize. Added line editor app. 2026-08-17 23:26:21 -04:00
Anachronaut 1d1a14318c Programs can now list and share vectors. 2026-08-17 21:39:27 -04:00
Anachronaut 9e3425d34b Programs can now pin specific routines to specific vectors in SplitBit assembly. Added snake game. 2026-08-17 19:12:42 -04:00
Anachronaut 08624925fe Interrupt on keypress mode 2026-08-17 16:02:44 -04:00
Anachronaut 91c9d49d1b CosmOS pre-alpha and launchable application versions of old programs. 2026-08-17 15:31:49 -04:00