Commit Graph
271 Commits
Author SHA1 Message Date
AnachronautandClaude Opus 5 db0c26e13f A bar for the drift, and a lighter touch sideways
A moon has no air, so a sideways drift never stops by itself and stopping
one means cancelling the velocity EXACTLY. That is not hard to do; it is
hard to do blind, which is what it was - a number nothing on the screen
said anything about.

So sprite one is a bar whose width is the drift. It runs right from the
middle of the screen for a rightward one and left for a leftward one, so
which way is as plain as how fast, and stopped is the one state with
nothing drawn at all. The whole of it is a target width written once a
frame; the device stretches one tile into it and the program draws
nothing.

Sideways thrust is one a tick rather than two. At two, the smallest
correction available was twice the size it needed to be and overshooting
was the normal outcome.

WHICH ZERO MEANS NOTHING TURNED OUT TO MATTER. A target width of nought is
the NATURAL width, not an empty sprite - so a bar with no drift in it came
out eight pixels wide, sitting at the middle of the screen, saying
"stopped" in the same shape it says "drifting slightly". What draws
nothing is a SIZE of nought, which is the other zero in the other byte.
Both meanings are deliberate and documented and it still caught me out
inside a week of writing them down.

The check for it earned its place by failing on that before it was found,
which is the best evidence a check can offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 22:14:36 -04:00
AnachronautandClaude Opus 5 b1dde7908c Lunar Porter flies on a controller
A held thruster burns every tick it is held for, which is the whole reason
the pad exists: the console can only say a key went down, so a thruster
driven by it could be pumped and never leaned on.

The burn happens on the same tick gravity does, and for the same reason -
a sixteenth of a pixel is the smallest step this arithmetic takes, and
applied sixty times a second it is an enormous acceleration. On the tick,
thrust and gravity are two numbers whose RATIO is the whole feel of the
thing. Position still moves every frame; only the acceleration is stepped,
and nothing can see that.

Two against gravity's one, so climbing and falling are the same speed.
Three was the first try and it left the moon after about a second of
holding.

If there is a pad the console's arrows are ignored, because under a window
the same keypress reaches both - the pad as a level, the console as a byte
- and a thruster that fired twice for one press would be a mystery to
anybody tuning it. q still quits, since a pad has no letter for it. With
no pad the arrows still burn once a press, which is the most that can be
done down a wire.

And break.sh now rebuilds the disk images as well as the binaries. Half
the things worth breaking here are SplitBit assembly rather than C, and
those live on the fixture disks - so an edit to a .asm file changed
nothing the suite could see, and the tool reported that nothing caught the
break. That is the exact lie it was written to prevent, turning up in a
new place. With the disks rebuilt it catches this one: the lander falls
between the two captures instead of climbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 21:58:31 -04:00
AnachronautandClaude Opus 5 fed1453e6e Controllers: four pads that say what is held
The console says WHICH KEY WENT DOWN, which is the right shape for typing
and the wrong one for playing. A game wants to know what is being held,
this frame, possibly several things at once, and a stream of presses
cannot say that: a key that is down and staying down sends nothing at all.
Lunar Porter's thrust is a burn per press for exactly that reason.

So a pad is its own device on ports 0x60 to 0x6F, reporting a LEVEL. One
read gives every button at once, holding is the natural thing to express,
two directions together cost nothing, and reading does not consume it - a
game may ask twice in a frame and be told the same thing both times.

Four of them, because a party is four. They cost a port each and nothing
at all when unused. The directions are the low nibble so "which way" is an
AND with 0x0F; the buttons are the high nibble for the same reason. 0x64
says which are really there, so a game can ask for a controller rather
than sitting silent while somebody presses things at it. They never
interrupt: a game polls once a frame because that is when it draws.

KEY-UP ON THE CONSOLE WAS THE OTHER WAY TO DO THIS AND WAS REJECTED. A
terminal hands over characters and can never report a key coming up
however it is asked, so it would have been a thing that worked behind a
window and silently did not down a wire. A separate device can honestly
say it is not there.

Voyager drives pad nought from the keyboard as well as from any real
controller, OR-ed rather than chosen between, so a game written for a pad
is playable on a machine with none and unplugging one mid-game does not
leave somebody holding nothing.

And a recorded path, which is what makes any of it testable: --pad names a
file of one byte a frame, and the manifest has an eighth column for it.
A BYTE A FRAME AND NOT A BYTE A READ - a level asked twice in one frame
has to answer the same both times, and a file that advanced per read would
depend on how the program happened to be written. Voyager's own tests run
headless with nobody holding anything, so without this the device would be
exercised only by somebody playing: the state the console's line editing
was in when it broke twice in two days.

0x50 is the timer, not free. The block this went in was chosen after
looking rather than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 21:44:55 -04:00
AnachronautandClaude Opus 5 88ecb208f4 The coarse scroll register was being sent the wrong register
CALL eighth
  OUTA 0x36

RET puts A back the way it found it, so the column origin was written the
high byte of the position that had been passed in, and the answer the
subroutine had worked out went nowhere. The fine register was computed
inline with OUTQ and was correct, which is exactly what it looked like
from the outside: smooth scrolling within a cell that never advanced one.

Q is the only register that crosses a RET. Every other answer in this
program already came back in it; this one had been written as if A would
do, and A very nearly does, which is what makes it worth a comment rather
than a fix.

Gravity was Jupiter's. A sixteenth of a pixel per frame per frame is the
smallest step this arithmetic can take and it crossed the screen in a
second, so it is applied one frame in six instead - which divides the pull
by six and costs a byte and a compare. The alternative was a finer unit
for velocity than for position, and that means a shift every time one is
added to the other, twice a frame, for ever.

And the check that catches all this now looks 1.5 million cycles in rather
than twelve. The first number came from assuming a program that saves a
whole screen takes a long time to start; it does not, and by twelve
million the lander had flown seven hundred frames and left the picture.
A capture near the beginning is worth more than a tuned one - there is
less between it and the start that can move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 21:13:09 -04:00
AnachronautandClaude Opus 5 d6c81fa32c Lunar Porter, rung one: it flies
A lander over a moon that wraps. Landing, crashing, fuel, cargo and bases
are not here - this rung exists to answer whether it FEELS right, because
everything after it is bookkeeping and none of it is worth building on a
lander that is no fun to fly.

The moon comes for free. The map's column origin is a ring in hardware, so
128 cells is 1024 pixels of surface with no edge and no seam to cross.

Position and velocity are sixteen bit in SIXTEENTHS OF A PIXEL, and the
unit is the design: gravity is a small number added to a velocity and a
velocity is a number added to a position, with no multiply or divide
anywhere. 1024 pixels is 16,384 sixteenths, which is 2^14 - so going all
the way round is an AND with 0x3FFF rather than a comparison, and it is
never wrong at the seam.

The lander never moves sideways. The world scrolls under it and it sits at
the middle of the screen, which is a byte a frame instead of two and is
also what makes the wrap invisible: there is no moment where it jumps.

One key is one burn. The console says which key went down and there is no
such thing as a key coming up, so a thruster cannot be held - a press adds
to the velocity once. That is a property of the machine rather than a
choice this program made, and it reads as pumping the engine.

Four bugs found by running it, all worth keeping written down:

  B CANNOT BE A LOOP COUNTER here. Every comparison is an INIB, so the
  count was overwritten by whichever bound was last tested and the loop
  reset itself for ever. Counters that outlive arithmetic live in memory.

  A subroutine answers in Q, and the AND after it read A. The moon came
  out flat because it was testing the height against 1 instead of the
  random number.

  Row minus height, not height minus row: they are equal at the surface,
  equal does not borrow, and the surface row has to be ground.

  And the shift register has A as its HIGH half. Written the other way,
  the view scrolled by 256 cells for every one it should have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 16:52:01 -04:00
AnachronautandClaude Opus 5 5222c85100 More fills the screen it is on, not the screen it was written for
Twenty two lines was right when there was one screen size. It still is on
the forty column screen and wastes three fifths of the eighty column one,
so More asks the rows register instead - which is readable for exactly
this sort of reason.

Rows minus three is twenty two on a twenty five row screen, so nothing
changed underneath anyone already reading files this way. It fills a
bigger screen and leaves a smaller one alone.

A bitmap screen has no rows and says so with a nought, which through an
eight bit subtraction would be 253 lines. Anything under five falls back.

The existing test stopped testing paging the moment this worked: 32 lines
fits in a 47 line page, so the file never paged and the recording lost the
prompt entirely. The fixture is 60 lines now - the INPUT needed moving,
not just the output, which is the failure this project keeps meeting.

And cosmosMoreNarrow, which runs Mode first and pages the same file on the
forty column screen. Two recordings of one file at 47 lines and at 22: a
More that went back to a constant would make them the same length.

Both were verified with break.sh, and the first attempt was a bad break
rather than a bad test - it replaced one of two reads of the rows port and
the other still fetched the real value. Which is a fair argument against
reading a port twice, so it is read once and kept now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 16:18:37 -04:00
AnachronautandClaude Opus 5 fba1b553d2 Depth: a ball behind the near pillars and in front of the far ones
The demo for what V5 added. Four pillars at four distances, each ONE 8 by
8 tile stretched to its own width and height, and a ball walking past all
of them at a distance between two.

What it shows is the thing an ordering cannot. The ball is sprite NOUGHT
and every pillar is numbered after it, so table order puts the ball in
front of all four - and it is still hidden behind two of them, because the
depth buffer is asked per column. Caught mid-straddle in the checks: the
ball is 48 wide and the pillar 32, so it shows on both sides and nowhere
across the middle.

Writing it found the conceptual trap in the feature, which is now written
down where somebody will hit it. The pillars first carried their own
distance in their entries AND wrote that same distance into their columns,
so each was asked whether it was in front of itself - and 20 is not nearer
than 20, so all four vanished. THE BUFFER IS WHAT HAS BEEN DRAWN AND A
SPRITE'S DEPTH IS A QUESTION ASKED OF IT. Scenery writes it; it does not
ask.

Also found that a scheme only gives a colour to index one. The default
palette sets each scheme's paper and ink and nothing between them, so
art drawn in index two comes out black until a program writes a palette.

And the fixture disk's root directory was full: four blocks, 32 entries,
all taken, so adding an app failed the whole disk build. Loudly, which is
the right way round - but it is a wall that moves for free, so it is eight
blocks and 64 entries now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 15:58:34 -04:00
AnachronautandClaude Opus 5 cb898450b5 Sprites that scale, and a depth buffer to hide them behind
A target size in PIXELS rather than a multiplier, which is the whole of
why this is usable here. A billboard at distance d wants to be k/d pixels
tall, and that is a number a program has anyway - out of a lookup table,
most likely. A multiplier would have to be a fixed point fraction arrived
at by dividing, and this CPU cannot divide.

Zero on an axis means the natural size, so every sprite written before
scaling existed still means what it meant.

The two axes are independent, and that shape - one tile wide at its own
size, stretched to whatever height a distance says - is a wall column in a
pseudo-3D game. Measured: a DDA step costs 85 cycles, so 80 columns of ray
casting is about 85,000 cycles, or 12fps. Drawing those walls from the CPU
instead would be 256,000 writes, fifteen frames of cycles for one frame of
screen. The device doing the pixels is what makes such a game possible at
all here, not merely faster.

And a depth buffer, one byte a screen column at 0xD000, written by the
program. A sprite with a depth draws only in the columns it is in front
of. PER COLUMN, and that is the point: a billboard is nearer than the wall
at one end of itself and further at the other, and no ordering of the
table can say that. Table order settles sprites against each other; the
buffer settles them against the scenery. Zero means no test at both ends,
so a program that never writes it behaves as it did before it existed.

The entry grew from 8 bytes to 16 - now, while two programs use the table,
rather than once a game is written on it. Bytes 0 to 7 kept their
meanings, so Sprite.asm needed no change.

The pass is rewritten to walk where a sprite is GOING rather than where it
came from, which is what makes a stretch and a squash one operation. It
also made flipping fall out: turning the source coordinate round mirrors
the tile order and the pixels inside each tile in one step, where drawing
tile by tile had to be told to do both. All 111 checks passed unchanged at
natural size, which is what says the rewrite changed nothing it should not.

Clipping moved out of the inner loop and had to: a target size is sixteen
bits, so a sprite asked to be 60,000 pixels tall would have been sixty
thousand turns of a loop that drew eight rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 14:07:38 -04:00
AnachronautandClaude Opus 5 f8c3db5d56 A tool for breaking things, since doing it by hand went wrong twice
A check that passes proves nothing until it has been seen to fail. Doing
that by hand failed twice in two days, and BOTH TIMES IT LOOKED LIKE A
RESULT - the suite ran, went green, and read exactly like "this check does
not catch that".

Once the edit produced code that would not compile, make failed, the exit
status was not looked at, and the previous binary ran the suite. Once the
anchor was right and the filename was wrong, so nothing was edited at all.

Neither had anything to do with header dependencies, which have always
worked: DEPFLAGS is -MMD -MP and every .d is included. What was missing
was a harness that refuses to report a result it did not earn.

So Tests/break.sh checks every step of its own work and treats anything
unexpected as a hard error rather than a green run. Not finding the break
is the answer it exists to give, and it is worthless if it can also be the
answer when the break never happened. It restores the file on the way out,
including on an interrupt.

It is not in the suite and docs.sh does not count it, for the reason
makedisks.sh is not counted turned round - but being left out of the count
is not being left out of the manual, and that gap is where a script goes
undocumented for months. So docs.sh now requires both of them to be
described, and caught this one being missing.

Also: video.sh reads the fixture disks and does not build them, so after
make sanitize clears the build directory it reported SEVEN product-looking
failures for a missing file. It builds them now and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 13:19:22 -04:00
AnachronautandClaude Opus 5 9eed23120f Four pages of tiles, in bits that were already there
A tile number is a byte and a byte reaches 256, which is not many once a
font has taken 135 of them and a game wants a character, a background and
a wall. Bits 4 and 5 of the attribute now say which page of 256 the
number is in - bits already written on every cell and every sprite, and
reserved for this since the attribute was defined.

Four pages of 16K is 64K, which is the whole atlas, so THE FOURTH PAGE IS
THE MEMORY THE SPRITE TABLE AND THE PALETTE ARE IN. That is not a hole in
the design; it is the answer shared video memory has always given, and it
is checked rather than forbidden. The atlas is 1024 tiles, and what a
program spends on sprites and colours comes out of them: no sprites means
page 3 is art, and sprites means 768 tiles and a reason.

The page is a property of the CELL and not a mode, so one screen shows
tiles from all four at once and nothing has to decide which page it is in.

Both places a tile is drawn from now ask one function where the art is.
They would otherwise drift: the sprite pass was written days after the map
pass and neither is where the other is looked at.

Nothing in CosmOS changes. The shell draws from page 0, which the screen
save covers; a tile left in another page is invisible unless a map cell
names that page, and the map is given back or cleared.

Both breaks were tried and both failed the checks - and the second had to
be tried twice, because the constant it needed lives in video.h and the
harness was only editing video.c. That is the same silent no-op as
yesterday's uncompiled break, in a different disguise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 12:59:02 -04:00
AnachronautandClaude Opus 5 a916103a7f Sprites: things that move without the screen moving
Everything drawn on this machine was in a cell. Something between two
cells meant rewriting both; something moving a pixel at a time meant
rewriting them sixty times a second, which is affordable for one thing
and not for twenty. A sprite is put at a pixel and the device draws it
over whatever is behind, so moving it costs two bytes.

MADE OF TILES, which is the decision the rest follows from: m by n taken
in reading order from one index, so there is no second pixel format, no
second kind of memory, and nothing a sprite can show that the map cannot.
A 16 by 16 character is four tiles and the background can name the same
four.

256 entries of 8 bytes at 0xC000 in the atlas - eight so the entry
address is a shift, the same no-multiply argument as the palette's four.
Position is signed and sixteen bits, because 640 by 400 does not fit in a
byte and a sprite has to be able to sit half off the left rather than
appearing whole at the edge.

A PIXEL OF ZERO IS NOT DRAWN, or every sprite is a rectangle. Tested
before the attribute is added, so a hole belongs to the art and not to
the colour scheme. The same rule the other way round is what "behind"
means: drawn only where the background pixel was zero, so a thing walks
behind a pillar and in front of the floor in one frame.

All of them draw, every frame, so they cannot flicker. Real machines
dropped them per scanline because they had a fixed number of shift
registers; this has a loop. The limit is the size of the table, which is
a constant rather than a property of what is on screen.

And the system takes them down at exit. The sprite table sits in the gap
the screen save walks around - to the end of the map, then the palette -
and that is right, because nothing the shell draws is a sprite: there is
nothing to give back, only something to take away. Otherwise a program
that put a ball up and left would leave it over the prompt, in front of
everything, with nothing able to type it away. Sprite.asm deliberately
leaves its own, because a program that faulted could not have cleared it.

Every check here was re-broken and failed: transparency, reading order,
draw order, priority, and size. Size needed breaking twice - the first
attempt did not compile, and a silent build failure had left the old
binary passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 11:42:11 -04:00
AnachronautandClaude Opus 5 eee95ef0ce Flip says the true thing, and the checks that let it lie
Two bugs, both in what the demo claimed rather than in the device.

The assembler has no string escapes, so the "\n" written in a literal
printed as a backslash and an n. A newline is a byte; Say.asm has always
written one as 0x0A 0x00 and this now writes it out of the console port.

And the line whose whole job was to still be there afterwards was wiped
out on the way back, because the program called osTakeScreen - which
restores the screen AS IT WAS BEFORE, so the tidy-up erased the one thing
the demo was pointing at. It did not need saving: nothing it touches is
the shell's. A program that damages nothing should not ask, and asking
anyway costs it the screen it was standing on.

Which turned out to be untrue as written, and that is the third thing.
Flip drew with a tile of its own, and the system copies the font back
over every tile at exit - so the filled screen went blank the moment the
program left, and the check that the system put the display back could
not tell a restored screen from an abandoned one. It passed with the
restore deleted. So did the check that a program can show the other
screen at all: a blank screen counts as one colour just as well as a
filled one does.

Now it fills with 0x0A, which is an asterisk in one of the reversed
colour schemes: paper is the colour and ink is black, so a whole screen
is drawn with NO TILE REDEFINED and it survives leaving. Both checks ask
for the commonest colour in the picture rather than counting colours or
naming a pixel - the font's only blank glyph is the space, whose
attribute nibble is nought, so a filled screen is always a pattern and
which pixel lands on paper depends on the character.

Both were re-broken afterwards and both failed this time.

Also cosmosFlip, a transcript test, which is what would have caught the
printed backslash in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 11:08:09 -04:00
AnachronautandClaude Opus 5 023362b05a A second screen, and one port to say which is shown
A screen drawn where it can be seen is seen half drawn. A program that
moves forty things and rewrites the map underneath them is wrong for as
long as it takes to put them all right, and at a megahertz that is long
enough to look at.

So the device brings a second screen bank, on port 0x3B, and port 0x3C
says which of the two is displayed. Everything a program draws into the
other one is invisible until one byte shows the whole of it at once.

ONE REGISTER IS ENOUGH, where the hardware this imitates needed two. The
other said which screen the CPU's window pointed at; there is no window
here, because a program reaches a bank through the memory controller by
its number. Writing to the screen that is not shown is a matter of naming
its bank, and the device never has to be told.

And a flip cannot tear: a frame is drawn from one bank in one go, so a
flip either happened before that frame or happens before the next. There
is nothing to race, where the real machines had to catch the few lines
between frames to swap in.

The console draws into whichever screen is displayed rather than one of
its own, so a fault message lands where somebody can read it even if a
game had flipped. And CosmOS puts the displayed screen back at exit, the
way it already puts back the cursor and the ink: a program that faulted
while flipped could not have, and a shell that only came out right for
programs which remembered would come out wrong the day one crashed.

Flip.asm is the worked example. It deliberately does NOT restore the
display itself - that is the point of the paragraph above, and it is what
makes the system's guarantee the thing under test rather than the
program's good manners. Written the other way round first, where it
passed with the guarantee deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 10:31:29 -04:00
AnachronautandClaude Opus 5 66e7b84272 The screen is two banks: an atlas and a screen
Tiles and colours are written when a program loads; the map is written
whenever anything moves. Sharing one 64K bank made them compete for room
neither needed all of, and had a worse consequence than being cramped: a
bitmap covers the whole bank, so entering bitmap mode destroyed the font.
A program could not draw a picture and then say anything about it.

Split, each gets a whole bank. The atlas holds the tiles and the palette,
the screen holds the map or a bitmap, and a picture now costs the map and
nothing else. It also leaves 48K free in the atlas, which is where the
sprite table and a second page of tiles are going.

No new mechanism was needed. A bank is registered by naming the port that
owns it, so a device with two banks needs two ports that own memory: the
base port keeps the atlas, since tiles have been at 0x0000 since there was
a screen at all, and 0x3A owns the screen. The registry now answers
honestly about which ports in the block bring memory, where it used to say
all sixteen did.

CosmOS never addresses video memory except in one place - the screen save,
which walks 196 pages of it. The page number already says which bank a page
is in, so screenBankFor works it out rather than keeping a second list
beside screenPageFor. Grid and picture.asm register both banks; colours.asm
only touches the palette and needed none of it.

Tests/video.sh names the memory every write is for, because an address
cannot: tile 5 and bitmap pixel 5 are both 0x0005, and a helper that
guessed would be right for the tiles and silently wrong for a picture.

And picture.asm gained a check, because this change broke it and nothing
noticed - registering the second bank leaves DestBank pointing at it, so
the palette went into the wrong one and the picture came out black. It was
the only thing here found by looking rather than by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 10:09:53 -04:00
AnachronautandClaude Opus 5 2abc8281df Loops, and the scripting language is a language
while and for. Both only mean anything in a script, because a loop goes back to
the line that opened it and a prompt has no line to go back to - and both say so
rather than doing something surprising.

THE SCRIPT READER KEEPS THE POSITION OF EVERY LINE before reading it, which is
what makes any of this possible: by the time a line has been read the reader is
past it, and a line is not a fixed size to subtract. Three words per line, and
the block is read again on the way back so the pointer into it means what it
meant - the same thing nesting one script inside another already did, for a
different reason.

THE TWO LOOPS END DIFFERENTLY, and that is the design rather than an accident. A
while is taken away at its end and its own line asks the question again, so
nothing has to be remembered. A for is not: how many words it has used is kept
in the block, and its line reads itself again and counts one more off the front.
That is a byte in a block instead of a copy of the word list in every one of
them.

Blocks grew from a byte to a record of sixteen - state, kind, words used, and
where the line that opened it was - and sixteen because A and B are a shift
register, so four rotations turn a block number into its offset. The history and
the variables are addressed the same way for the same reason.

Nested loops, an if inside a loop, a loop inside a branch nobody takes, and a for
with no words: the last two run no times rather than once, which is the case
worth having a test for.

Three things found by running it:

textSame asks whether two WHOLE strings are the same, so "in red green blue" is
not "in". The word has to be split off before it is compared.

A for typed at a prompt complained about while, because both arrive at the same
place. One message that names neither is better than one that names the wrong
one.

And docs.sh caught a naming convention nobody had written down: it recognises a
packed name by its label ending in "Name", so ForName2 was silently not counted.
It failed the right way round - saying the run was shorter than the count claims
rather than passing - but the convention now lives where the names are and not
only in the checker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 20:50:05 -04:00
AnachronautandClaude Opus 5 16f8232a35 Lines that are only run sometimes
if, else, end, and same.

IF TAKES A COMMAND, which is one rule rather than two and is why comparing
values needs no syntax of its own: "same" is an ordinary command that fails when
its two words differ, so "if same $a $b" falls out of the rule instead of being
an exception to it. Anything else that can fail is a question too - "if load
Snake.sbx" is a perfectly good one.

The shell already had the other half. LineFailed exists because a script stops at
the first line that did not work, so every command was already saying whether it
had, for a different reason entirely.

A BLOCK HAS TWO KINDS OF NOT-RUNNING. One where an else would turn it on, and
one where it would not - which is what an if pushes when something above it is
already being skipped. That is what makes nesting need no looking down the
stack: the top of it says everything.

A branch nobody is taking is not even looked at. The skipping happens BEFORE the
names are filled in, so a variable mentioned in a branch that is not running is
not an error - a line nobody runs must not be able to fail.

AND LINES MAY BE INDENTED, which they could not be before there was anything to
indent inside. Nobody writes an if inside an if without indenting what is in
them, and a leading space used to make the first word empty and match nothing.
Found by writing the test script the way anybody would write one.

CALL commandFailed became BRI commandFailed in nine places. It never returns - it
marks the line and branches to the prompt - so calling it was a lie that cost a
Stack frame each time, and fourteen other sites already branched. THE LINT RULE
FOUND THIS, three days after I wrote the rule and on my own code: two false
positives that were really the linter being right about a CALL that is not one.
It does not fix the leak on its own, since a failure inside any called routine
still abandons that frame, but it removes the cause of the commonest case and
makes the code true.

The mechanical edit then left a BRI prompt stranded behind one of them, and the
linter caught that too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 20:33:33 -04:00
AnachronautandClaude Opus 5 4b109f704c The shell starts each line with the Stack where it left it
Every failure in this shell abandons a frame. commandFailed is reached with CALL
and never returns: it marks the line and branches to the prompt, which is the
idiom every command uses and is why a failure needs no unwinding anywhere. What
it costs is the frame of that call and of everything between the prompt and it -
twenty bytes for a name that was never set, more from somewhere deeper - and
nothing ever gave them back.

MEASURED BEFORE IT WAS FIXED. Twenty failed lines moved the Stack Pointer from
FFFD to FE6D, and it only ever went one way.

Nothing had noticed because it takes thousands of failures to reach anything and
nobody types thousands of anything. A loop in a script would, which is why this
is worth doing before there are loops rather than after.

So the loop starts each turn from a known place. SystemStack is NOT that place:
it is taken when a program starts, so that the shell's Stack can be given back
when the program stops - which means it holds wherever the shell had got to at
that moment, the value that needs correcting rather than the one to correct
from. ShellStack is taken once, at boot, when nothing is happening.

Second use of MVDS in the system, and it earns it for the same reason as the
first: a Stack that is right by construction beats one that is right because
everybody remembered.

Break prints the registers, so the test is two dumps with eight failures between
them and a requirement that they agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 19:29:20 -04:00
AnachronautandClaude Opus 5 a8707f29f0 Names for things
"set apps /Apps", and then "$apps" anywhere on a later line stands for it. A
name stops where a name stops - letters and digits - so it composes into a path
without anything having to be quoted, which is the whole reason a script would
want one.

THE SUBSTITUTION HAPPENS ON EVERY LINE THE SHELL IS ABOUT TO RUN, typed or read
out of a file, so the two behave the same and no command below has to know that
variables exist. Same shape as the line editing: one place the whole system
already flows through, rather than a decision made twenty times.

A NAME NOTHING WAS SET TO DOES NOT RUN THE LINE. Every other shell expands it to
nothing, and that is the wrong answer here: a mistyped name would quietly become
an empty path, which is the class of silent wrong answer the rest of this system
spends its effort refusing. It says so and the line counts as failed, which
stops a script - and the test proves that by running one, where the line after
it must not appear. Somebody who wants an empty value writes "set name" and gets
one, so the escape hatch exists and has to be asked for.

A NAME TOO LONG IS AN ERROR RATHER THAN A SHORTER NAME. Cutting it off at
fifteen characters was the first version, and it is the same fault wearing a
different coat: two names differing only after the fifteenth would be one
variable, and the complaint about a missing one printed a word nobody typed.

Eight slots of sixty four bytes - sixteen of name, forty eight of value - and
sixty four rather than eighty because A and B are a sixteen bit shift register,
so two rotations turn a slot number into its offset. The same trick the history
uses, and the reason neither needs a multiply this machine has not got.

TWO THINGS I GOT WRONG AND ONE I FOUND:

doSetVar ended in RET. It is BRANCHED to from the dispatch, not called, so that
RET went wherever the Stack happened to point - the same fault that formatted a
disk last week, in a command written three days after the rule was named. The
new lint rule does not catch this shape: it fires on falling INTO a subroutine,
not on a branch target that ends like one.

And a test of the expansion's answer, which is dead code: commandFailed does not
return. It marks the line and branches to the prompt, the way every failure in
this shell is reported, so the only way out of the expansion is the one where it
worked.

Which turned up a real leak, measured and not yet fixed: every failure that goes
through commandFailed abandons the frames between the prompt and the call. SP
goes from FFFD to FE6D over twenty of them, twenty bytes each. Its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 19:23:55 -04:00
AnachronautandClaude Opus 5 3c76934a9a Tab reaches the disk
Paths and programs, which is the half that makes it worth having. The first word
of a line is a command or a PROGRAM, offered under the name somebody would type
- the extension taken off - and anything after it is a file, offered as it
really is. A separator anywhere in the word says which directory to look in.

A directory answers with a separator on the end instead of a space, which says
what it is and lets the next part be typed straight away. The answer ending in
one is also what stops a space being added, so that is one test rather than a
flag.

PROGRAMS ARE LOOKED FOR WHERE THE SHELL WOULD LOOK to run one: where you are,
/Apps on the disk you are on, and /Apps on drive 0. Offering something the shell
would not find would be finishing a word into a thing that then does not work.
Drive 0's is skipped when that is already the drive, or every program in it
would be offered twice and nothing would ever be the only match.

Walking somebody else's directory means standing in it, which is the only way to
walk one here, so where the person was and which drive they were on are put down
first and restored whatever happens.

Three bugs, all found by running it:

THE DIRECTORY TEST WAS INVERTED. dir asks the same question the same way round
four hundred lines further up, which is what made it obvious once looked at.

THE /Apps WALK OVERWROTE THE TYPED PATH. The whole search runs a second time to
list the matches, and by then TabDir said "/Apps" - so a word that had named
nowhere went looking in the wrong place and listed nothing at all. Two ways into
the walk now, and the typed path is never written over.

AND LISTING ONLY KNEW ABOUT COMMANDS, because it was a second copy of the walk.
It is the same walk with a flag now: finding the answer and showing the matches
are the same question asked twice.

Also cosmosMonitor, which had been RE-BLESSED INTO MEANINGLESSNESS by the wall
move. It disassembles a loaded program, at an address the input names - and that
address moved a page while the recording was simply re-recorded to whatever came
out, which was a page of zeroes. It is pointed at 5000 again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 18:10:40 -04:00
AnachronautandClaude Opus 5 bb065fe221 Tab finishes a word somebody started
The first word of a line, against the shell's fifteen commands. One match goes in
with a space after it, because a word that can only be one thing is finished.
Several are folded into their longest common prefix and that goes in, which is
the most that can be said without guessing which was meant - and if that adds
nothing, the matches are listed and the line put back underneath.

THE LINE COMING BACK IS THE HALF I EXPECTED TO BE HARD and it was already
solved. The prompt has been reprinted somewhere else entirely, so the editor's
idea of where the line begins is wrong - but editAnchor works that out backwards
from where printing ended, precisely so it survives the screen moving. Listing is
a redraw it already knew how to do.

editInsert became editPut, a routine, because completing a word puts in several
characters and every one of them is that. Which cost a bug immediately: the old
inline code left the insertion point in A, and a RET puts A back to what the
caller had.

Two more bugs worth naming, both mine and both the same shape - a pointer that
had moved:

THE CANDIDATE'S START HAS TO BE KEPT. The comparison walks DP3 through the name
as it matches, so by the time a match is declared, DP3 points at the part AFTER
what was typed - and that is what got copied. "he" completed to "he" because the
answer taken was "lp".

AND THE INSERTION STOPS AT OR PAST, not exactly equal. With the wrong answer the
two counters passed each other and the loop ran off the end of the buffer,
filling the line with whatever was next in memory. They cannot pass each other
now, and the branch stays, because the cheaper failure is worth nothing.

MY OWN TEST HAD A HOLE and breaking the code found it. The later-word case
pressed Tab after a space, where there is nothing to finish anyway, so it passed
whether or not the shell checked which word it was on. It types "echo he" now,
which would become "echo help" if it did not.

The assembler's label table went past 1024 and is doubled. A ceiling reached
once will be reached again, and it is pointers into source already in memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 17:43:48 -04:00
AnachronautandClaude Opus 5 749fef8ce2 The shell's own words, as a table and not just a chain
The dispatch is a run of "is the line this name" comparisons. That is fine to
execute and impossible to WALK, and completing a half typed command needs to
walk them - so the names have to be data as well as code.

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

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

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

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

Groundwork for Tab completion. Nothing uses it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 17:21:30 -04:00
AnachronautandClaude Opus 5 b04e4b7d1c Finish making the tune a program
The user's conversion, which I reverted while I was working out whether it was
half done or broken. It was half done: an sbx application wants its #Include
above #Program, because services.asm ends in a #Vectors block and a #Base
written after that has no segment to be the base of.

So the include moves up, the bases move a page with everything else, and the
test starts it from the shell instead of booting it. It plays for 9,469,987
cycles, 9,423,527 of them waiting, which is the same 567 frames of music it
played as a boot image.

BEING A PROGRAM MEANS ITS VECTOR IS THE SYSTEM'S TO INSTALL. It brings the
screen's, so that it has a beat to play to, and CosmOS puts it in when the tune
starts and takes it back out when it stops - a thing a boot image never had to
have right, and the second program here to exercise the version two format at
all.

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

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

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

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

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

Twenty three recordings are the same runs a page higher.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 16:21:24 -04:00
AnachronautandClaude Opus 5 925388c2f2 Keep the personal disk three starts back
Copied before every start of the machine, and kept several deep rather than
copied over one file.

ONE BACKUP TAKEN AT EVERY START IS WORSE THAN NONE. The way a disk is lost is
that something goes wrong - and the very next thing anybody does is start the
machine again to see how bad it is, which is exactly when a single backup gets
overwritten by the wreckage. Three deep means the damage has to happen and then
be started past three times before the copy that would have helped is gone.

AND IT IS NOT GUARDED BY A CHECK THAT THE DISK STILL LOOKS RIGHT, because no
such check can be written. The disk that went missing this week was a perfectly
valid and perfectly empty filesystem: the format had succeeded, and there is
nothing to look at that says a disk has lost anything. That is the whole reason
to keep the old ones rather than to judge the new one.

The backups sit outside clean's reach like the disk itself. A backup a rebuild
deletes is not one.

The message the disk prints when it is first made said "nothing in this makefile
will touch it again", which stopped being true the moment this was added. It now
says written to, and says where the copies go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:54:55 -04:00
AnachronautandClaude Opus 5 c1b3c4c156 A rule for the bug that formatted a disk
falls-into-subroutine. The code above a label ends without going anywhere and
the label is one something CALLs, so execution walks into the subroutine,
reaches its RET, and returns to whatever the Stack happens to hold - because
nobody called, there is no caller, and it goes somewhere nobody named.

It is worth a rule because the symptom is nowhere near the cause and changes
with the Stack. In CosmOS's monitor it was usually a byte that does not decode,
in the middle of newLine; once it was inside sbfsFormat, and the machine
formatted the disk it had booted from.

Two exemptions, and both had to exist or the rule would have reported well
written code:

A TAIL CALL IS THE SAME SHAPE AND IS FINE. Falling out of one subroutine into
another means the RET returns to the outer caller, which is real. So it only
fires when nothing since the last branch or return was a call target either -
which is the linter's usual trade of precision for being worth reading.

AND osExit NEVER RETURNS. It is how a loaded program gives the machine back, and
every program here ends with it and then writes its helpers underneath. Without
that, twelve well written programs were reported. It is the one name from the
system this tool knows, and the comment says why it is there.

Also SRET, which stopsFallthrough did not list. It returns from a handler
exactly as RET returns from a call, and leaving it out is a gap in every rule
that asks what reaches an instruction. Load bearing rather than tidy: without it
cosmos.asm reports a handler ending in SRET as falling into the routine written
under it.

A first pass over the file collects call targets, because a subroutine is very
often called from further down than it is written.

The corpus reports none of it, which is the point rather than a disappointment,
and the Test Manual now says so - a baseline entry that is absent is otherwise
indistinguishable from a rule that never runs. Checked against the version of
cosmos.asm from before the fix, where it names the line.

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things found on the way:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:08:20 -04:00
AnachronautandClaude Opus 5 000a6d39cb Somewhere to send the fault about there being nowhere to send it
Dispatching through a vector with nothing in it was the one fault this machine
could not hand over, because the thing that would hand it over is the thing that
has just found nothing to hand it to. It stopped the machine and no program
could do anything about it - so calling a service the system does not implement
was fatal, and that is an ordinary mistake to make.

Two new fault vectors: 5 when a software vector was empty, 6 when a device
interrupted and its hardware entry was. Separate, because they are separate
mistakes with separate fixes - one is a program calling something that is not
there, the other a program that asked to be interrupted and forgot the handler.

WHICH ENTRY WAS EMPTY ARRIVES IN Q, and it is the only thing on this machine a
handler is given in a register. Not a fault cause register by another route: the
vector still says what happened and Q says which of the 256 entries it happened
about, which is a parameter and not a cause. It costs no new state at all,
because the frame already saved the Q the interrupted program had and RETI puts
it back.

The escalation happens once. If vector 5 or 6 is itself empty the machine stops
the way it always did, having genuinely run out of places to go.

swiFaultTest is what guards that, and it was written long before any of this: it
installs nothing, so it must still get the old halt. Breaking the escalation
fails the two new tests and not that one; making the escalation unbounded fails
that one and not the two new ones. Each break fails exactly the half it belongs
to.

noDeviceTest is fed no input on purpose. The console raises its line once when
input ENDS as well as when a byte arrives - which exists so a program driven by
interrupts is told when nothing more is coming - so with no input at all, that
end is what turns up.

Groundwork for CosmOS's fault screen, which wanted to catch these two and could
not.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reported by the user, who wrote the program.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 11:47:20 -04:00
AnachronautandClaude Opus 5 373454ec00 A fresh disk for every test, fixtures included
A fixture built by makedisks.sh was handed to each test where it lay. Twenty
four tests name disks/cosmos.img and several of them write to one, so a test
could hand the next one a disk with its leavings on.

romBoot is what found it. Its recorded output described a directory that
selfBoot had made earlier in the same run, so it passed in a full run and failed
on its own - which is the worst way round for a test to be wrong, because the
form nobody runs is the one telling the truth. Its recording now says "made"
like selfBoot's, which is what running the same input on the same disk should
always have said.

Fixed as a class rather than as an instance: run.sh copies a fixture before
attaching it, the same way it already removed a scratch image. Then every one of
the 138 run and rom tests was run on its own to see whether anything else was
leaning on what ran before it. Nothing was, before or after.

Also, cosmosEditKeys.in was written by Python's write_text, which encodes as
UTF-8, so every key byte was 0xC2 and then the key. The test passed anyway,
because the shell ignores a byte it has no use for - a fixture working for a
reason it was not built on, which is exactly the thing that stops working
without anybody touching it. Written as bytes now; the recording is unchanged,
which is the proof the stray bytes were being ignored.

docs.sh is what caught that, and it turns out to draw the line in the right
place by construction: a deliberately binary fixture does not decode as UTF-8
and is skipped, while one that is accidentally UTF-8 decodes and is reported.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 23:01:55 -04:00
AnachronautandClaude Opus 5 b3726c950a Deliver the keys that are not characters
An arrow key has never reached this machine. Voyager threw it away for want of
anywhere to put it, and a terminal sent ESC [ A, which arrived in the middle of
whatever was being read and made it unrecognisable - typing Up at the CosmOS
prompt put three bytes in the command line and got "I do not know".

So the console names them: one byte each, 0x80 upward, above ASCII so nothing
written before them can collide. Up, Down, Left, Right, Home, End and forward
Delete, with room above for the paging and function keys.

The console normalises, which is what it already does. Behind a window it turns
the key somebody pressed into a byte; on a terminal it turns the sequence into
the same byte. That is the act it has always performed on Return and Backspace,
one layer further along, and it is why a program need not know which of the two
it is talking to. What a key MEANS is not the console's business - that belongs
to whoever is reading, the same way what is on a disk belongs to the system and
what a drive is belongs to the machine.

Translated only when standard input really is a terminal. Nothing else sends
these sequences, a pipe holds exactly the bytes somebody put in it, and it keeps
the Escape-or-Up timing problem out of every test here: a test writes the key
values themselves. Line mode drops them, in both front ends, because line mode
delivers characters and a line somebody else has finished editing cannot be
moved about in.

Press.sbx says what it was handed, in hexadecimal and by name, and reads a line
before it reads keys so both halves of that rule are checked. Two recordings,
one fed as standard input and one as a keyboard, agreeing byte for byte; each
break fails exactly one of them. Three checks in terminal.sh type real escape
sequences at a pseudo-terminal, which is the only place they are ever read as
sequences: that they arrive as keys, that Escape alone is still Escape, and that
a character typed straight after an escape is held rather than swallowed.

Five recordings re-blessed for Press.sbx appearing on the shared disk, and the
whole of that diff is the file's own line and the counts above it.

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

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

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

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

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

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

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

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

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

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

Two things cost time and neither was the feature:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 17:56:41 -04:00
AnachronautandClaude Opus 5 6b51d6391f A beat a program sets for itself
The only regular thing on this machine was the screen finishing a frame,
sixty times a second and not negotiable - a clock a program BORROWS rather
than one it sets. Every duration became a multiple of 16,667 cycles, so a
sixteenth note at 120 beats a minute, which is 125,000, is seven and a half
frames and cannot be asked for at all. The way round it was to choose a
tempo whose subdivisions happen to land on whole frames, which is making
the music fit the machine. Examples/tune.asm says so in its own header.

  0x50  Status: a period went by, it is running, it will interrupt
  0x51  Control: run, repeat, interrupt
  0x52-0x54  The period, in cycles, most significant first

THE PERIOD IS IN CYCLES because that is what everything else here is
counted in - the cost model counts them and a frame is measured in them -
so a timer counting anything else would be a second unit to remember.
Twenty four bits reaches from one cycle to sixteen and a half seconds, with
120 beats a minute at 500,000 in the middle, and there is no range left for
a prescaler to buy.

Starting loads the period; asking it to run while it already is does not,
so turning interrupts on half way through a period does not silently move
the beat being kept. What is left over carries into the next period, so a
period of 1,000 ticks every 1,000 and not every 1,000 plus however late
anybody looked. Reading the status takes the tick down and the line with
it, which is the rule this machine settled two days ago about every status
port.

The timing check is in terminal.sh and not the manifest, and the reason is
worth keeping: settle() strips cycle counts from recordings, which is right
for every other program and useless for a clock. "It printed eight dots"
would pass on a timer that fired them all at once. terminal.sh measures
that eight periods of 125,000 come to a million within a couple of hundred
cycles, and that 99.97% of them were spent asleep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 17:06:28 -04:00
AnachronautandClaude Opus 5 3e48e9690d Put a disk of your own in drive 1
make run-voyager and make run-cosmos now attach Disks/personal.img as the
second drive. It is made the first time it is wanted and then left alone:
a rule with no prerequisites, so make never looks at it again.

NOT UNDER build/, and that is the whole point. Everything else in this
repository is made from source and can be thrown away without losing
anything, so 'clean' empties build/ without a thought - and a disk of your
own that a clean deletes is not a disk of your own. It is the one place
here where something MADE ON THE MACHINE can live, which starts to matter
the moment there are tools on it that make things.

Disks/ is in .gitignore for the same reason: what you make on the machine
is yours and not the repository's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 16:45:17 -04:00
AnachronautandClaude Opus 5 e4f4bae762 Work across two disks: copy between them, and run a program from one on
files from the other

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

COPYING NEEDED TWO THINGS TO REMEMBER A DRIVE.

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

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

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

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

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

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

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

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

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

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

Two bugs, and the second is the interesting one.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 12:30:09 -04:00
AnachronautandClaude Opus 5 b1538e0618 Give the disk four drives, behind one controller
SEVERAL DISKS ARE ONE CONTROLLER AND NOT SEVERAL DEVICES, and the
instruction set decided that rather than taste. A port is an immediate byte
inside the OUT that names it - portOut takes it from Program Memory - so a
program cannot compute one. "The disk on port 0x20 plus drive times four"
is not something this machine can say, and two disks as two devices would
mean a branch on the drive number in all eleven places sbfs.asm names a
disk port. A drive register is what a floppy controller has always been.

  0x24  Drive, which the block, command and status registers refer to
  0x25  Drives, read only: how many are plugged in

--disk given more than once fills them in order. What is per drive is the
image, its size and its write protection; the block register, the status
and the one buffer belong to the controller, which is the same division
real hardware makes.

A drive that is not there is refused rather than wrapped, because wrapping
means a program asking for a drive this machine has not got quietly reading
the one it has - the same shape of fault as taking a bank number somebody
else was using. An EMPTY drive is a different thing and is selectable: a
controller has its drives whether or not there are disks in them, and
reading one fails with the error bit the way an empty drive should.

Changing drives finishes whatever the one being left was in the middle of.
A transfer waits for the clock, so one may be owed at any moment, and
running it against the disk that is arriving would be a fault with no
owner.

Also stops parseOptions setting its defaults field by field. It was nine
assignments beside a struct, and a list beside a thing drifts from the
thing: adding two fields left them holding whatever was on the stack, so a
machine given one disk was told it already had four drives. It is one
zeroing now, and a default that is not nought can be written under it where
it reads as the exception. That struct growing a field once before left
Voyager linked against an object that disagreed about its size.

Nothing in CosmOS uses any of this yet. The mount record is next.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 21:14:07 -04:00
AnachronautandClaude Opus 5 1aa45fcfc4 Grid: fill the map, not the window
Scrolling sideways walked off the end of what the program had filled, so
the grid went blank for six seconds and then came round again. A map row
holds 128 cells - 256 bytes at two a cell, whatever mode the screen is in -
and an eighty column screen shows eighty of them, so 48 were empty.

This is the third thing this loop has counted and the first right one. It
said forty, which filled half the screen. Then it asked the screen how wide
it was, which fixed what could be seen and was still wrong. ASKING THE
SCREEN IS RIGHT FOR FILLING A SCREEN AND WRONG FOR FILLING A MAP: a program
writing one screenful wants the window, and a program that scrolls wants
everything the window can be moved over. There is no register for that
because it is a property of video memory rather than of the display.

The check that should have caught it did not, and that is the more useful
half. periodic.py looked at 32 pixels - four cells at the left edge - so it
could not see a gap that was on the right, and at the cycle count it samples
the origin had moved to column 22 and the gap was off in the middle
distance. It reads the whole scanline now and says which column the picture
stops repeating at, which is how the two failures above were told apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 19:00:28 -04:00
AnachronautandClaude Opus 5 bcd42e75ca Scroll the screen sideways, and by less than a cell
The screen could move one way, a cell at a time. Three registers were
missing and this adds them: a column origin so the map can be wider than
the screen as well as taller, and a pixel remainder for each axis so the
step can be one pixel rather than eight.

  0x36  Scroll column, in cells, wrapping at 128
  0x37  Fine X, 0 to 7 pixels
  0x38  Fine Y, 0 to 7 pixels

FINE DOES NOT CARRY INTO COARSE. Writing 8 to a fine register writes 0,
because only its low three bits mean anything. The alternative was for a
write of 8 to step the coarse register, and it was rejected for one reason:
a program that scrolls has to know where it has got to, and if the hardware
carries then the only way to find out is to read the register back. Keeping
them apart means the program already knows, because it did the arithmetic
itself. It is also what the machines this one is pretending to be did.

The renderer now draws one more row and one more column than fit and clips
them, because with a fine offset the screen no longer begins on a cell
boundary and the cells at two edges are partly off it.

videoPutCell follows the column origin as it has always followed the row -
a caller means a cell of the SCREEN, and the screen is a window onto the
map. The fine offsets are deliberately not applied there: they move the
finished picture by less than a cell, and there is no such thing as less
than a cell to write into. So a program may scroll to any pixel without the
console's idea of where row three, column five is moving underneath it.

Grid now scrolls diagonally, a pixel a frame, in four port writes and two
carries. It moved eight pixels every fourth frame before, which reads as
the picture jumping rather than travelling.

Seven checks, each one the same program with one register changed, so what
is compared is where the picture stopped. Breaking fine X, fine Y, the
column origin, the three-bit mask, or the console's use of the origin each
fails exactly one of them.

Grid's own two checks had to be rewritten, and the reason is worth keeping:
they asked whether pixel 4 was a grid line, which was really a check that
the scroll happened to be at a cell boundary. A picture that moves a pixel
a frame can only be asked things that are true at every offset - that it
repeats every eight pixels, and that one band of eight rows holds different
colours from the next.

Also repairs docs.sh, which found the minimal CosmOS application by taking
the first asm block in the README. Documenting a program with an example
above it made that a different block, and the check complained that the
minimal application had no #Base about something that never claimed to be
one. It looks under System Services now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 18:52:15 -04:00
AnachronautandClaude Opus 5 848103f5e4 Grid: ask the screen how wide it is
It said forty and filled exactly half of an eighty column screen.

CosmOS asks for the wide mode when it starts, because its own help text is
seventy-four characters across. So a program that assumes the shape the
MACHINE wakes up in is wrong about the shape the SYSTEM is running in - and
the Programming Manual says as much where it describes the columns and rows
registers: how big the screen is, is asked for rather than assumed.

Port 0x32 says. One instruction, and the program now fills whatever it is
given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 17:46:49 -04:00
AnachronautandClaude Opus 5 1a8a5efe03 Grid: the first program to use the screen as a screen
Everything drawn on this machine so far has been text or a bitmap. The tile
engine has been there since the screen was built and only the console had
touched it, and only ever to put a letter in a cell - the one thing it can
do that a plain character display could do too.

Grid redefines a tile, fills all 128 map rows with it, and scrolls by
writing ONE BYTE A FRAME. Nothing moves. The rows above and below the
screen are already drawn, so a screenful of movement costs one OUTA and the
rows that leave the top are still there.

Its tile goes at 200 because the machine wakes with the font in tile memory
- glyph n at tile n, for 135 of the 256 - so a program starting at zero
paints over the alphabet and the shell it is about to hand the machine back
to. Its sixteen colour bands are one tile and not sixteen: the attribute
nibble is added to every index in a cell, so the same 64 bytes come out in
sixteen colourings.

Three things it cost, all of them the same lesson about this machine:

  - "SETD.0 X" then "STD.0.1" stores through DP1, which had not been set
    yet. It assembles, and the blit then reads its 64 bytes from wherever
    DP1 was last left, so the tile came out as noise.
  - The palette entry for scheme n is at 0xFC00 + 64n, which reaches
    0xFFC0 - four pages, not one. And doubling A by adding B needs B to
    hold A, which RSTB is the opposite of. Both went away by writing all
    256 entries in order and letting the controller step the address, so
    nothing computes an address at all.
  - The screen it hands back had the right cells and the wrong colours,
    because restoring the map is not restoring the palette.

That last one is a gap in the machine rather than in this program, and is
written up in the CosmOS README. The console's colours live at exactly the
entries the attribute nibble lands on, so any program using the nibble
overwrites them and has nowhere else to write. Grid puts bank 0 back - grey
on black - and leaves the other fifteen. The real answer is a command to
the screen meaning "give me back what you woke up with", the way the
console has one for clearing. There is not one, and this is the first
program that ever wanted it.

Two checks in video.sh, which boots the whole system and reads the pixels
the renderer produced rather than trusting what the program believed.
Breaking the tile fails one and breaking the attribute fails the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 17:38:30 -04:00
AnachronautandClaude Opus 5 0e0731e2b1 Put the cursor home when the screen is cleared
A screen with nothing on it and a cursor half way down it is not a cleared
screen: the next thing written lands where the last thing happened to leave
off, at a position whose meaning was just erased. The shell's clear did
exactly that, and left the next line one row below wherever it had been.

Both halves were missing. consoleClearScreen blanks the cells and does not
touch cursorRow or cursorColumn, and 2J on a terminal empties the screen
without moving anything - H is what puts the cursor at the top.

Life and Snake never showed this because they follow their clear with an
explicit 1;1H of their own. They were working around it, which is why the
bug survived until a command cleared the screen and then let somebody type.

The attribute is deliberately not reset. Clearing is about what is on the
screen rather than how the next thing will be drawn, and a program that
chose a colour and then cleared still wants that colour - which is what a
terminal does too.

Two checks in video.sh, and they took two goes to make independent. The
first pointed at the row where the letter lands when the cursor is NOT
homed, so removing the clear and removing the homing failed the same pair
and neither said which. The second now looks at a row nothing writes to
either way - and at a letter whose ink actually reaches the pixel it reads,
which "two" did not, so it passed on a screen that had never been cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 17:08:27 -04:00