Commit Graph
270 Commits
Author SHA1 Message Date
Anachronaut 61a80ae13d Put the manual's sections back in opcode order
The user's edit. Moving branches to 0x60 and subroutines to 0x70 left their
sections sitting where they used to be numerically, between the ALU and the
registers, so the manual read in an order the opcode map no longer did.

The list has always been arranged by opcode sequence rather than by
importance or by how often a thing is used - arbitrary, and now canon.
2026-08-27 18:27:46 -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 54ff7196c9 Stage 0: the emulator carries the ROM, so a disk is enough
./SplitBit --disk system.img
  stage two
  CosmOS
  >

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

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

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

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

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

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

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

Three things in it worth knowing:

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

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

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

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

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

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

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

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

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

And SplitLint caught the second in code written an hour after the baseline
that catches it. In the blit set-up, RSTA writes a source address of zero
and then RSTA writes a bank number of zero - two unrelated quantities that
are equal by accident, in the most safety critical file in the repository.
It is marked with a reason rather than removed.
2026-08-26 23:31:04 -04:00
Anachronaut 612bd1b97c B1: a boot area on the disk, reserved by arithmetic that was already there
The first rung of booting from disk. A boot area is blocks between the
superblock and the directory that the filesystem never allocates and never
sees, and NOTHING WAS ADDED TO RESERVE THEM: both implementations work out
the first usable block as directoryStart + directoryBlocks, and
directoryStart has always been a field rather than a constant. Formatting
with the directory moved up reserves everything below it. Neither allocator
changed, on either side.

Two new superblock fields in bytes that were reserved: bootBlocks at 14,
per slot, and bootSlot at 16. A disk made before this has zero in both,
which reads as "no boot area" - true, and the same shape as the version two
parent field, where the value an older disk already held was the right
answer without conversion.

TWO SLOTS, ALWAYS. A boot slot is raw blocks with no entry to rename, so
the write-a-temporary-and-rename ordering that protects every file cannot
protect it, and a machine interrupted while updating its only slot would
not boot at all - the one failure on this disk with no way back. Writing
the slot that is not live and then moving one byte makes that a machine
that boots what it had before.

bootBlocks and directoryStart say the same thing from two sides, so a disk
where they disagree is refused rather than guessed at, as is one naming a
slot that does not exist.

Checked where it matters: the HOST formats a disk with a boot area and the
MACHINE fills it, then the reserved blocks are compared against zero. The
machine's allocator is the one that had no idea any of this was happening,
which is what makes that the check worth having. Six host checks besides,
including both halves of the superblock disagreeing.
2026-08-26 22:58:45 -04:00
Anachronaut 0a2965bc63 Name the rules, say when there is nothing to say, and hold a baseline
Four things SplitLint wanted, and they build on each other.

EVERY WARNING NAMES ITS RULE, in brackets at the end the way a compiler
names the flag that produced it. Twelve rules, listed by --help. That makes
the other three possible: suppressions can name one rule and leave the line
honest about the others, the harness can assert on a rule's identity rather
than on the wording of its message, and --machine can print one tab
separated line per warning - file, line, rule, message, help - so nothing
downstream reads prose. This file's own output was parsed with regular
expressions three times in one day before it had a shape to rely on.

A CLEAN RUN SAYS SO:

  No style warnings: 121 files checked against 12 rules.

It used to exit in silence, which does not tell you it found nothing - it
tells you nothing at all, and from outside the two are identical.

A MARKER THAT SILENCES NOTHING IS ITSELF REPORTED, as dead-suppression. An
exception that outlived whatever made it necessary is the thing the
required reason exists to prevent, and naming the wrong rule now gets you
both the warning you meant to silence and a note that your suppression is
doing nothing.

AND THE CORPUS IS HELD TO A BASELINE. Sixty one warnings are left in it
deliberately and nothing stopped a sixty second. Tests/lint-baseline.txt
records how many of each rule each file should produce, so a new one fails
make test while the sixty one stay quiet; confirmed by adding an INIA 0d0
to Say.asm and watching it name the file, the rule and the count. It counts
per file and rule rather than recording line numbers, because line numbers
would churn the whole baseline whenever anything was inserted above a
warning - the same reason cycle counts are stripped from recorded output
here. ./Tests/lint.sh --bless records it again.

One thing to know for next time: the rule name was inserted before the line
number at all twenty one call sites, and the signature was changed to match
rather than the twenty one call sites being fixed. (path, rule, line) reads
no worse than (path, line, rule) and one edit has fewer ways to go wrong
than twenty one.
2026-08-26 21:00: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 c146d98588 Stop the linter recommending a change that a faster helper would break
SplitLint knew that CALL restores A, B and Data Pointers 0 to 2, so a
pointer set before a call is still set after it. That is true, and it made
the tool give advice that was correct today and unsafe to take.

Of the 178 redundant SETDs it found across the corpus, 122 were redundant
ONLY because of that restore - the shape is everywhere, because it is how a
helper is given its arguments:

  SETD.0 SbfsBlock
  SETD.2 SbfsFileStart
  CALL sbfsSetWord
  SETD.0 SbfsBlock        <- flagged

Removing that last line is right until sbfsSetWord is reached with RCAL,
which restores nothing - and that is not hypothetical, it is what RCAL was
added to this machine for, measured at close to halving the assembler's
memory traffic. The failure would also be silent from the linter's side: it
forgets everything across an RCAL, so it would stop reporting while the
removals stayed removed.

So a claim now ends at any call, for pointers and for registers, the way a
claim about carry already did. 257 warnings become 127, and the redundant
SETDs 178 become 54 - which is exactly the number an independent count of
"no CALL in between" had arrived at separately.

The fixture gained a SETD and an INIA repeated across a CALL, which must
stay quiet, and the harness fails with the old behaviour put back. Two
mistakes worth recording: the new expectations first pointed at the LABEL
above the repeats rather than the repeats, which passes for free because
nothing ever warns about a label; and the block landed in the middle of
another check's comment, leaving that comment describing the code below it
instead of its own.
2026-08-26 17:42:56 -04:00
Anachronaut 8f4cc5878d Repair a table cut in half, and measure the numbers that had drifted
The README's emulator options table was split by forty lines of prose: two
rows, then the whole discussion of the cost model, then five more rows with
no header above them. Markdown renders that second half as something other
than a table, so three of the seven options were not being shown as
options. The rows are back together and the prose follows them.

Four numbers had gone stale, in three different ways, and none was noticed:

- "Five more scripts run alongside it" - there are six, and lint.sh had no
  bullet saying what it was for.
- "rebuild all three tools" - there are four.
- Files.asm quoted at 645 bytes in two places; it is 665.
- Edit quoted at 1,983; it is 1,996.

The last two are the most quotable sentences in the CosmOS README and the
least likely to be rechecked by hand: the programs kept being made better
and the sentences about how small they are stayed where they were.

So docs.sh measures all four now. It counts the scripts in Tests/ that are
not the driver or the disk builder and checks the README says that many and
explains each one; it counts what the makefile's all target builds and
checks the tool count in both phrasings, which took two attempts because
one sentence says "the four tools" and the other "all four tools"; and it
assembles every app the CosmOS README quotes a size for and compares. Each
check was confirmed by making the fact wrong and watching it fail.

WAIT also added a second kind of cycle this morning and the cost model
section still described only one. It now says what an idle cycle is, why
the two are counted apart, and what that distinction is FOR - a machine
that slept through a slow disk and one that spun on it take the same
elapsed time and print the same characters, and only the split tells them
apart. The duplicated sentence about pipelining is gone; it was said twice,
eleven lines apart, in nearly the same words.
2026-08-26 17:32:22 -04:00
Anachronaut 2b079324ae Add SplitLint, and let it be told when something is deliberate
SplitLint reports valid assembly that has a shorter direct expression:
zero loads that could be RSTA or RSTB, Q moved through the stack where MVQA
would do, self-cancelling push and pop pairs, assignments overwritten
before use, unreachable fallthrough, one-byte pointer moves that could be
INCD or DECD, a branch to the label directly below it, a SETD reloading an
address the pointer already holds, and branches whose carry is known.

Its model is deliberately local and conservative: every label and every
directive forgets all known state, so a claim only ever lives inside a
straight-line region. It knows the calling convention - CALL forgets DP3
and keeps the rest, RCAL and SWI forget everything - and it shares
assembly.o with the assembler, so an added opcode cannot leave it holding a
private copy of the instruction table.

260 warnings across the corpus, of which three were wrong in the way that
matters: branchTest.asm and interruptFlagTest.asm exist to check that a
branch whose carry is known behaves correctly, so a diagnostic saying the
outcome is known is exactly right and exactly unwanted.

A line whose comment says "splitlint: <reason>" is now not reported on. THE
REASON IS REQUIRED and a bare marker is refused, because a suppression
nobody explained outlives whatever made it necessary. Suppressed warnings
are not counted, so --fatal-warnings does not fail on one, and the number
of them is printed at the end so the claim is visible rather than silent.

Tests/lint.sh checked a TOTAL: twenty three warnings expected, twenty three
found. That number stays right while the thing behind it goes wrong - a
rule that stopped firing while another fired twice would pass, and so would
a rule reporting at the wrong line. It now checks which warning came out
and at which line, that nothing else came out, and that the four lines
meant to stay quiet did. Confirmed by breaking one rule's message and
watching it name that rule: the old assertion passed the same sabotage,
because the warning still fired and the count never moved.

Written with the user while I was away; my part is the suppression
mechanism, the harness rewrite, and the three marks in the test programs.
2026-08-26 17:23:17 -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 6b41354f8f Delete a comment describing a design that was removed, and check the rest
Two comments in the native assembler survived the changes that made them
false, and both are the kind that misleads rather than merely ages.

Asm.asm still explained an eighteen kilobyte buffer that the whole output
had to fit in "because a file is written in one call and there is nowhere
to put half of one" - which stopped being true when the assembler learned
to stream, and the variable it described, ImgRoom, does not exist any more.
It was sitting in front of the comment that replaced it, so the paragraph a
reader met first described the design that had been taken out. Replaced by
what is actually there: no limit but the disk, one block at a time through
a window in scratch.

scratch.asm said the system keeps below 0x1000 twelve lines above the
paragraph explaining that the system's half of Data Memory was doubled to
0x1FFF. A stale number next to its own correction is worse than a stale
number alone, because which one a reader believes is a coin toss.

docs.sh now checks both of the facts scratch.asm asserts about the machine
around it: the floor it claims the system keeps below, against the CosmOS
README, and the address its buffers start at, against where the assembler's
own data actually ends. Neither is enforced by a line of code anywhere -
the map is a comment, deliberately, because reserving the buffers would put
22K of zeroes in the file and the assembler could not load itself - so a
check is the only thing that can hold them. Both fail when broken; the
second reports the exact overlap.
2026-08-26 10:28:14 -04:00
Anachronaut c74075dc51 The review notes are all done, so the file that said to delete it goes
Six items, all fixed and all tested: the streamed commit bounded against
its reservation, the memory map checked against itself, the save guarantee
stated precisely, temporaries marked in the entry rather than by name, the
path limits measured and left alone, and the directory bounded to what the
parent field can name.

The list of design strengths at the end went with it. It was a reader
describing what the format gets right, which the README says in its own
words and at more length; keeping a second copy in a file headed 'delete
me' would only be a second copy to keep current. It is in the history if
anybody wants to read it back.
2026-08-25 23:47:12 -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 634650cab9 The path limits are settled: leave them, and say what they are
Measured rather than adjusted. The longest path on a full install is 21
characters against a native cap of about 117, so neither that nor
SplitDisk's 512 binds anything, and both can be raised if that ever
changes. Looking for the evidence turned up the limit that did bind - the
prompt's 127 bytes, fixed in the commit before this one.
2026-08-25 23:35:40 -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 0c240f7ad3 The temporary namespace is no longer a note
Two left: one path length both implementations agree on, and a bound on
directory entries that keeps every one of them nameable as a parent.
2026-08-25 23:22:43 -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 d6cbbb5034 Strike the three review items that are now fixed
The streamed commit is bounded, the memory map is checked against itself,
and the save guarantee says what it is. Three left: the temporary-file
namespace, one path length both implementations agree on, and a bound on
directory entries that keeps every one of them nameable as a parent.
2026-08-25 22:56:21 -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 e0cf0a9a25 Charge the memory controller for the memory it moves
A blit cost ten cycles, which were the five port writes that set it up. The
quarter of a kilobyte that moved cost nothing, and no hardware moves a quarter
of a kilobyte for nothing.

BANKS ARE SEPARATE MEMORIES, AND THAT IS WHAT SETS THE RATE. A move between two
of them can overlap its read and its write - fetch the next byte while the last
one is stored - so it settles at a byte a cycle. A move within one bank cannot,
and costs two. A fill has nothing to read and costs one whatever the banks are.
The odd cycle on each is the pipeline filling.

That is not a modelling choice so much as a reading of the structure the machine
already has: a Program to Data blit is inherently twice the rate of a Data to
Data one, and it is legible why.

Measured: 256 bytes is 297 cycles across banks and 518 within one, both
including the instructions that ask for it.

WHAT IT TAUGHT, which was not what I expected. Charging for movement costs the
native assembler 0.4 per cent and costs directory work 13.4. The assembler reads
a block and then thinks about it for a long time, so the move is amortised into
nothing; the filesystem reads a block in order to look at it and does nothing
else in between.

So the case for a blitter that runs alongside the CPU is weaker than it sounds.
Concurrency pays when there is other work to do during the transfer, and the
place that spends its time moving memory is exactly the place with nothing else
to do - it blits a block precisely so that it can read it. What that workload
wants is a FASTER controller, not a concurrent one: a wider data path halves the
wait, and the machine is waiting either way.

Video is the case that would still want concurrency, since a frame can be moved
while the next one is worked out. That is an argument about software nobody has
written yet, and it is now an argument with numbers on the other side of it.

The byte at a time port is charged too, for the byte it moves beyond reaching
the port. Nothing polls CTRL_STATUS, so the transfer stalls whoever asked for
it, which is the conservative reading and the one the software already assumes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 21:11:47 -04:00
AnachronautandClaude Opus 5 f1e5cc46f6 A cycle is an access to memory, not an instruction
cycleCount used to tick once per instruction, so RSTA cost what SETD cost and a
CALL moving ten bytes of Stack cost what a branch cost. No machine anybody could
build works that way, and the emulator's job is to be the thing the hardware is
designed against.

Every touch of memory now goes through one of four accessors that charge for it:
fetching an opcode, fetching the bytes after it, reading or writing Data Memory,
and reaching a device port. One access, one cycle, nothing overlapped. The
accessors exist so the cost is counted where the access happens rather than in a
table of per instruction costs kept somewhere else - a table like that is a
second copy of what the code does, and the two drift.

The run loop spends a budget of cycles instead of running a count of
instructions, so the emulated rate means something: an instruction costs what it
touches, and a batch ends when the cycles are gone.

What the numbers say now: RSTA 1 and SETD 4, being one byte and four. LDA 3,
DPUA 2. CALL and RET together 24, RCAL and RRET together 8, because the first
pair moves twenty bytes of Stack and the second moves four. The average SplitBit
instruction costs 3.72 of these, measured over the native assembler assembling a
program.

And the measurement that prompted all of this: converting the filesystem's
hottest leaf routine to RCAL is 3.1 per cent cheaper on a directory heavy
workload. The old model said 0.0, which is what a model that cannot see memory
traffic must say about a change that is nothing else.

Three tests moved. settle() strips the cycle count from recorded output, so
nothing should have churned - but it was anchored to the start of a line and
replCalculator's last output has no newline on it, which leaves the halt message
mid line where the pattern never reached. Not anchored any more.

The two Life programs are bounded by a cycle count because they never end, and
that number was rescaled from 3,000,000 to 11,200,000 - the same amount of work
at 3.72 cycles to the instruction. Nothing about either program changed. No limit
reproduces the old output exactly, because the cut now lands elsewhere in a
frame, so they are recorded again rather than tuned to match.

Whether hardware overlaps a fetch with the end of the previous instruction is
left open on purpose. This is the conservative model; pipelining is a decision to
make while drawing the hardware, not one to inherit from an emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 20:44:38 -04:00
AnachronautandClaude Opus 5 54f5cfe8a4 Add Copy and Compare, which stream in both directions at once
Copy joins the read and write streaming services: source and destination are
both larger than Data Memory while the program owns one block. Compare reads
two files through separate blocks and ignores the bytes past a short final
block, which belong to neither file. Between them they exercise empty,
exact-block, part-block and 84,000 byte files, and the host extracts the copy
afterwards so that two native programs agreeing with each other is not the only
oracle.

Written by ChatGPT, as their headers record, along with the agree.sh section
and the manifest entry that drive them.

THIS SHOULD HAVE COME FIRST. The commit before it staged whole files rather
than the hunks it meant, so its manifest already names these two programs while
their source was still untracked - that commit will not build on its own. Left
in place rather than rewritten, since the pair is right and only their order is
wrong.

NOTES.md is their review of the streaming work. The first item in it is fixed
by the commit before this one; the rest are still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 20:21:43 -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 19ab36a201 The assembler can say where everything ended up
-S writes every label and the address it was given, in address order.

Nothing else knows that. A program on the disk is bytes; the monitor can
disassemble it but has no idea what any of it is called. So counting which
addresses a program calls says a great deal and names nothing - the answer
arrives as a column of numbers and somebody works out by hand which routine
each one is inside.

It was deferred when the native assembler was planned, as a listing and symbol
dump nobody needed yet. Finding out where the assembler spends its time is what
needed it: the top six call targets were addresses until this existed and are
numStep, numCompare, tokGet, srcNext, numAddByte and clsSameName with it.

Sorted by address rather than by name, because the question asked of it is
always "what is at this address".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 18:15:50 -04:00
AnachronautandClaude Opus 5 af0360128b Sixty four instructions becomes seventy
The six settled back on the twenty fourth, built now.

RCAL and RRET are a call that puts nothing back. CALL restores A, B and Data
Pointers 0 through 2, which costs ten bytes of Stack and is why a subroutine
here can only hand anything back through Q, DP3 or memory. RCAL costs two and
restores nothing, which is what a short leaf routine wants and is unsafe in
exactly the way the name says.

They are a pair because the frames are different sizes: returning from one
through the other walks the Stack to somewhere that was never a return address.
That was the user's correction to the original proposal, which had a raw call
and no raw return.

DPUA and DPDA offset a Data Pointer by A; DPUW and DPDW by A and B together,
most significant first. DPUP and DPDN take a byte written into the program, so
moving a pointer by something just worked out meant storing it and loading it
back. Down as well as up on symmetry grounds, which was also the user's call -
the argument against it came from counting uses in a corpus written under the
constraint.

The opcodes sit where they belong: 0x16 and 0x1E immediately below CALL and RET,
and 0x4E through 0x51 at the end of the Data Pointer family. All six fit shapes
that already existed, so instructiontable.py needed only set membership and both
machine side copies of the table regenerated from it unchanged.

Checked at every level it exists at: the emulator runs them, the host assembler
encodes them, the monitor disassembles all six with the right lengths, and the
assembler that runs on the machine builds a program using them byte for byte
identically to the host - and that program runs.

The recorded test measures what the two calls COST as well as what they put
back, because an RCAL that quietly did what CALL does would still return to the
right place. It does not survive that: returned through RRET, it hangs.

docs.sh can read a two word number now. The count of instructions taking a Data
Pointer went past twenty, and the pattern only allowed one word, so the check
would have reported that the manual had stopped saying it rather than that the
number was wrong.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 17:13:49 -04:00
AnachronautandClaude Opus 5 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 7cd5e34347 Two ceilings a hundred bytes apart look like one ceiling
The sixteen kilobytes taken back a moment ago all went to the output image,
because that was the wall: 13,245 bytes of cosmos.bin against 13,312. Lifting
it moved the machine straight into the next one, a hundred and eleven bytes
away - the label names, at 8,081 of 8,192 - and the index was a hundred and
eighteen entries from the same place.

So the room is shared out rather than given to the obvious one. Names and index
both double, and the output takes what is left, which is still four and a half
thousand bytes more than CosmOS needs.

LabLimit and LabRoom in labels.asm have to agree with the map in scratch.asm
and are now said to.

Worth recording how this was found, because it is the good case. The assembler
STOPPED and said "no room left for label names: Mode, at line 3598" - a limit
it checks, names, and points at. Every other ceiling this project has hit went
unnoticed until something downstream broke: a program loaded over the shell, a
path silently cut short, a file reported as itself less 65,536. A limit that
announces itself is worth the handful of instructions it costs.

The output's eighteen kilobytes are temporary. They exist because the assembler
holds a whole finished file in memory before writing it, and the file is
produced in order, so it could be written as it is made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 14:50:36 -04:00
AnachronautandClaude Opus 5 ec56439d9a Take back the sixteen kilobytes the scratch map left behind
The assembler's buffers start at 0x8000, and the reason written beside them is
that everything above its own data is free. That was true when it was written.
The system kept below 0x1000 then; its data now reaches 0x1FFF, and the
assembler's own moved from 0x1000 to 0x2000 with it when both halves of the
memory map were doubled. The floor came up and the map stayed where it was,
leaving sixteen kilobytes between the two that nothing touched at all.

Starting at 0x4000 takes that back, with nearly four kilobytes of slack still
in front of it and room for the assembler's own data to double before the two
would meet.

All of it went to the output image, because that is the buffer that is actually
full. cosmos.bin is 13,245 bytes against the 13,312 it had, which is sixty
seven bytes - the next thing added to the system would have made the machine
unable to build it, and it would have shown up as a test going red rather than
as anything explicable at the machine. The label table has room yet, so this is
not the moment to share the space out evenly.

This is the third time a limit here has gone unnoticed until something hit it,
and the second where the cause was a number that stopped being true rather than
one that was ever wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 14:47:33 -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 588e02aff5 Double CosmOS's half of the machine, and check that it fits
The memory map gave CosmOS 0x0000 through 0x1FFF of Program Memory and
applications 0x2000 and above. CosmOS is 8141 bytes at the previous commit,
which is fifty one bytes short of the line, and the next thing added to it
went over.

GOING OVER DOES NOT FAIL WHERE IT HAPPENS. Nothing enforces the division: an
application says where it goes with #Base and the loader puts it there, so a
CosmOS that has grown past 0x1FFF simply has the next program loaded written
over the end of it. What breaks is whichever part of the shell that program
happened to cover, at whatever later moment somebody uses it. It turned up here
as the monitor's assemble command answering "I do not know" to valid
instructions, several commands into a session, on a machine that had booted
perfectly well.

Both halves are doubled: applications now start at 0x4000 in Program Memory and
0x2000 in Data Memory. That is 16K of code and 8K of data for the system,
against the 8775 and 2948 it uses today. Both were on the same trajectory, and
moving them together means the twenty files that say #Base are edited once
rather than twice.

The standalone loader's loadable.asm keeps its old base: it belongs to the
loader CosmOS grew out of, not to CosmOS, and its addresses answer to a
different program. The unbased-segment diagnostic keeps its old base too - it
exists to produce an error message that names the address, and the message is
what is recorded.

Tests/docs.sh now reads the two limits out of the table in the README and
measures both segments against them. It reads them rather than being told them
because the table is the specification, and this is the second time in this
project that the thing nobody checked is the thing that rotted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 22:21:02 -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 78e9eef472 D1: teach SplitDisk directories, without moving a byte
SBFS version two adds directories out of space each entry had already set
aside: two of the four reserved bytes become a parent, and one of the seven
spare flag bits says an entry is a directory. The entry is still thirty two
bytes, so it still divides two hundred and fifty six and still never straddles
a block, and nothing in the block layer knows anything happened.

A directory is an entry with no blocks. That is what keeps the flat array of
entries the whole allocation map, which is the property the format is built
on: with files laid down contiguously, every block is inside some entry's
range or it is not, and an entry with no range is in nobody's way. There is
still no allocation table to consult and none to keep right.

THE PARENT IS AN INDEX PLUS ONE, so zero means the root. A version one disk has
zeroes in those bytes, and "in the root" is exactly where every file on a flat
disk is - so a version one image is already a valid version two image, with
nothing to convert and no tool to convert it with.

A disk is at the lowest version that describes what is on it. format makes a
version one disk and mkdir is what raises it, so everything built here stays
readable by a reader that has never heard of a directory right up until it
really does have one. That is what lets this land before the machine knows
anything: the whole existing suite passes untouched.

The tool gains mkdir and rmdir, and list, put, get and delete take paths. list
also now reports entries used against entries available, because a disk has two
ceilings and the entry one is the one nobody notices until it bites.

rmdir refuses a directory with anything in it, and that is not politeness:
parents are entry indices, a freed index gets handed out again, and the
children of a removed directory would reappear inside whatever took its place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 18:41:11 -04:00
AnachronautandClaude Opus 5 f4fb56606e Give the demo disk room for names, not just for bytes
The demo disk had eight directory blocks, which is sixty four names, and
thirty nine of them were already spoken for. Its two ceilings were nowhere
near each other: the average file on it is twenty six blocks, so sixty four
names run out with the disk forty one per cent full. Names were going to be
gone long before space was, and space is what a disk is usually short of.

Twenty four blocks is one hundred and ninety two names. The difference costs
sixteen blocks of four thousand and ninety six, three tenths of one per cent,
and the superblock has carried this number per disk since the format was
written - so nothing but the makefile line knows or cares what it is.

This is provisioning and not format. Directories, when they arrive, will not
relieve this pressure; under the settled design they add to it, because a
directory spends an entry of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-24 18:31:55 -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