Compare commits

..
270 Commits
Author SHA1 Message Date
AnachronautandClaude Opus 5 09c72e4951 srcNext asks its own questions instead of calling for the answers
Two calls per character of every pass: numCompare to ask whether the
buffer is used up, and numStep to move one further into it. Inlined, the
compare settles almost every time on the high bytes and settles them
FIRST - a full block is 256, so SrcCount's high byte is one and SrcAt's
is nought until the last sixteenth of the block. Six instructions where
the call was twenty.

And the newline test uses A, which still holds the character, where it
used to fetch it back out of the SrcChar it had just been put in.

              before        after
  hello      586,184      574,737
  Say      4,506,702    4,333,689
  Files    7,188,351    6,863,744
  Keys    15,069,880   14,399,801
  cosmos 789,982,899  772,826,841

Two to four and a half per cent, which is worth having and is much less
than expected - AND THAT IS THE INTERESTING PART. It says the per
character cost is not in the reading. Now that comments no longer come
through here at all, what is left is the tokenizer above it, and the
remaining time is there rather than in getting the bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 21:43:29 -04:00
AnachronautandClaude Opus 5 2e9cb9af7e Comments are thrown away by the reader, not read one byte at a time
A comment used to walk through tokGet and srcNext and be classified,
character by character, on the way to being discarded. Comments are most
of what this assembler reads: 87 per cent of what Say.asm pulls in once
services.asm is counted, 59 per cent of CosmOS. The house style here is
dense commentary, so the assembler is penalised more than most by its own
sources.

The saving is not the classifying, it is the BOOKKEEPING. srcNext loads
and stores the walking pointer through memory for every character and
asks numCompare whether the buffer is used up. srcSkipComment keeps the
pointer in a data pointer for a whole run and the newline in B, so a
comment byte costs a load, a compare and two steps. A run is capped at
255 so one byte can count it, which is the only reason it loops.

Measured, each version with its own rebuilt images:

              without        with
  hello       791,957     586,184
  Say       9,924,401   4,506,702   2.20x
  Files    13,197,710   7,188,351   1.84x
  Keys     23,091,447  15,069,880   1.53x
  cosmos  886,498,996 789,982,899   1.12x

Which tracks the comment ratios: Say gains most and cosmos least, in
proportion to how much of each is prose.

---- And two mistakes worth keeping ----

The scratch went among the READER'S STATE, which is a block copied whole
by a count written down somewhere else - so every saved file lost the
last four bytes of itself and an include came back with its pointer
wrong. The comment above that block says not to do this, in capitals.
That is twice this week: scriptCopyState had the same shape this morning.

And a file that ends inside a comment has to put back the file that
included it, exactly as srcAtEnd does for a character. NOTHING IN THIS
REPOSITORY ENDS THAT WAY - every source here ends on a line of code with
a newline after it - so break.sh could not catch an error in that path
because nothing reached it. tail.asm is generated with no newline on its
last line for that reason, and usestail.asm names a label after the
include, which is what goes missing when the include never returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 21:33:15 -04:00
AnachronautandClaude Opus 5 e20c9bac1f The label table is kept in order, and halved instead of walked
labFind walked the index from the front, so every use of every label
cost a scan of every label defined so far, each with a string compare.
The cost grew with the program being built, which is what made it hurt:
assembling CosmOS on the machine took 1,833,691,267 cycles against the
assembler assembling itself at 57,257,133 - three times the source for
thirty two times the time.

Sorted and halved, the same build is 886,498,996. THE WALK WAS 52 PER
CENT OF THE WHOLE ASSEMBLY, which settles a suspicion this project has
carried unverified for weeks and puts a number on it.

Eleven comparisons against two thousand entries where a walk averaged six
hundred and seventy. The search hands back where a name WOULD go, which
is what adding one needs and what a walk could never have offered, so
labAdd gets its insertion point for nothing.

sameText was already an ordering and did not have to change: Q is the
difference at the first character that differed, and the Carry Flag from
that same subtraction survives the return because nothing puts the Status
register back. A name that runs out while the other carries on borrows
against the other's character, which sorts the shorter first.

Small programs pay about a tenth more - 57.3M to 63.5M for the assembler
on itself - because adding a label now moves the tail of the index up and
a short table was never expensive to walk. That is the right way round
for a trade to fall.

numHalve and numBack are new: a rotate right on a CIRCULAR sixteen bit
register brings bit nought back in at the top, so halving means taking
that bit off again, and the low half has to go down first because the
mask wants B.

WHICH END THE TABLE IS SORTED FROM DOES NOT MATTER. labAdd takes its
insertion point from labFind, so the comparison that decides the order is
the same one that searches it - turn it round and the table is built
backwards and read backwards and no output changes. Tests/break.sh says
so, correctly, by not noticing.

Verified by CosmOS builds CosmOS and second generation staying byte
identical. An indexing bug cannot hide behind a fixed point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 21:02:05 -04:00
AnachronautandClaude Opus 5 563bc20a75 Tab lists its matches in columns, and says what it can in colour
Tab's listing was six instructions: print the name, print two spaces. It
was the crown of this shell for a while and looks plain next to ls.

Columns cost NOTHING EXTRA. Tab already walks its candidates twice - once
to find the answer, once to show the matches, and tabRunSources exists
because those are the same walk asked two questions. The longest match is
counted on the first, which already visits every one of them, so the
listing needs no walk of its own to know how wide a column should be.
Padding goes before a name rather than after, so a row ends on a name.

Directories are blue and the shell's own commands are green. Both are
free: Tab appends the separator itself, and a built-in is not a file at
all - which is also the only way anybody could know it will run.

A FILE IS NOT COLOURED, and that is a decision. Whether a file will run
is a read of its first block, which is what ls does and what makes ls
cost twice what it otherwise would. Here it would be worse than slow:
candidates are offered from inside a directory walk, and looking a file
up would overwrite the very fields holding the walk's own position. Doing
it safely means holding every candidate name in memory, which is a buffer
the shell would carry whether anybody pressed Tab or not - and ls is one
keystroke away. A Tab press already costs about 200,000 cycles, so the
read was not the objection.

---- And the machine could no longer build itself ----

Found by make test, not by reading. The native assembler ran out of room
for label names on cosmos.asm: 16,758 bytes against 16,384. The index was
1,341 of 1,536 in the same breath.

Which is scratch.asm's own warning happening a second time - "two
ceilings a hundred bytes apart look like one ceiling until the first is
lifted" - so both were raised, out of the seventeen kilobyte page that
file deliberately left unclaimed against exactly this. Names to 26,624
and the index to 2,048, both left about a third clear, with 1,792 bytes
still unclaimed for the same reason.

A name is thirteen bytes on average and an index entry is four, so the
arena will always be the one that speaks first. That is now written down
where the two numbers are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 20:18:56 -04:00
AnachronautandClaude Opus 5 eaeb473176 ls says in green what the shell will start
Directories were already blue. Something the shell will run is green now,
and it is asked by LOOKING rather than looked up.

This was priced once as needing a runnable bit in the directory entry,
set by the filesystem from a list of magic numbers it would have to be
taught - declined twice, for putting format knowledge in the filesystem
and giving two implementations a registry to keep in step.

It costs nothing of the sort any more. The shell decides what to run by
reading a file's first block, so "will this run" is a question with an
answer already, and this asks the same one the shell would: SBEX for a
program, "#!" for a script. Nothing is written down and nothing has to
agree about anything.

The price is a block read per file, which is exactly what the bit existed
to avoid: a listing of thirty seven files went from 260,593 cycles to
635,321. It is paid in ls and not in dir on purpose - dir is the listing
you audit and is built into the shell, this is the one you read and was
loaded off the disk anyway. The fast one stays fast.

plain.script is the proof on the test disk: it sits among a dozen scripts
that are green and is not one, because it is the fixture with no shebang
and the shell will not start it. The extension is decoration and the
colour is the truth, which is the whole of what running by content means,
finally visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 19:53:33 -04:00
AnachronautandClaude Opus 5 afda6ca83b Finishing says nothing, which is what finishing looks like
The shell printed "finished" every time a program gave the machine back.
That made sense when starting a program and getting the machine back was
most of what the machine did and the prompt was the only other thing on
the screen. Under a listing, between two commands, it is noise.

And it was printed whatever the program made of it, so a program that had
just explained what went wrong was answered with the word "finished" -
"there is no such directory: nowhere" and then, immediately, "finished".

The prompt coming back is what says a program is over. It is the same
argument osLastStatus is made of: a program that failed has already said
so in words, and anything the shell adds beside that is the shell talking
over it. A program that FAULTED still says so, because the fault screen
printed in red above and a program that is gone should not look like one
that ended.

A NEWLINE ONLY IF ONE IS WANTED. A program that stopped part way along a
line would leave the prompt sitting in the middle of its last output,
which the word used to prevent by accident. The console knows which
column the cursor is in, so the shell asks - where a blank line printed
every time would be right about half of the time, and an empty directory
listed with ls would be followed by a blank line for no reason.

Fifty nine recorded sessions lose the word. Two of them move a cursor up
a row with it, which is the same fact seen from the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 19:49:43 -04:00
AnachronautandClaude Opus 5 a13bbeb4db ls, a listing laid out to be read
> ls
  Copy.sbx   Say.sbx    Where.sbx  Walk.sbx   ls.sbx     Lander     where.sh

dir says what is there one line each, with sizes and a tally of the disk.
This says the names in columns and nothing else, which is what you want
ninety times in a hundred. Two programs rather than one with a switch,
because they answer different questions and neither answer is a worse
version of the other.

AND THIS IS A PROGRAM WHERE DIR IS BUILT IN, which is the difference that
matters when the disk is what you are doubting: dir is already in memory
and this has to be loaded off the disk it is about to list. The daily
driver and the diagnostic.

Two passes over the directory and no buffer at all. Columns need the
longest name before the first line can be printed, which usually means
holding every name - twenty four bytes each against a format that allows
1,024 entries. Walking twice costs a read of each directory block, into a
buffer that is already there.

Across and not down. Real listings go down the columns so that names next
to each other alphabetically are next to each other on the screen, and
that reason depends on sorting - which nothing here does. With the order
arbitrary, down-and-across buys nothing and costs a division.

Directories in blue and wearing a separator, unfinished saves in red,
everything else plain: whether a file is runnable cannot be known without
opening it, and opening every file in a directory to colour a listing is
a price nobody agreed to pay. The colour reaches a terminal as well as
the screen, so it is one mechanism and not two.

---- Three bugs, and one of them is this machine's oldest trap ----

nameLength answered in A, and a RET puts A back the way the caller had
it. So it answered with nothing, and every gap between the columns came
out the same width because the padding was subtracting whatever A
happened to hold. Q is the ALU's output and nothing puts it back, which
is why every answer here comes home in it.

The padding subtracted the other way round - the name from the cell -
which borrowed on every name that was not the longest, so all of them
took the "wider than its cell" path and the listing came out separated by
one space.

And padding after a name left trailing spaces on every line that did not
fill its last column. It goes before the next name now, so spaces only
ever fall between two things and a line ends on a name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 19:29:02 -04:00
AnachronautandClaude Opus 5 323d7a0330 A listing says what is left of the disk, and what will fit
dir said what was there and nothing about what was left. SplitDisk has
printed the free figure since it was written, so the machine's own
listing was the poorer of the two implementations at describing the same
disk.

  43 files, 3 directories
  46 of 64 entries, 1893 blocks free

Entries first, because they are the ceiling nobody notices until they hit
it: a disk of small files runs out of directory slots long before it runs
out of blocks.

COUNTED RATHER THAN ASKED. The superblock keeps a free count and this
file calls it "a note rather than the truth" in three places. sbfsSpace
reads the whole directory table instead, which costs a read per directory
block and is the answer rather than a guess. SplitDisk goes on reading
the note and saying when it is stale, which is the right place for that
check - the host tool is what you audit a disk with.

---- And it is a fact about the disk, not about where you are ----

The first version added the blocks up as the LISTING walked past them,
which cost no extra read and was wrong: that walk stops only on entries
in the working directory, so the same disk came out as 1,996 blocks free
from the root and 2,025 from /Apps. Comparing against SplitDisk is what
said so, which is what having two implementations is for.

---- The longest run, which is what decides whether a file fits ----

  4 of 16 entries, 37 blocks free
  the longest run is 25

Files are laid down contiguously, so the free total does not say whether
a file will fit. Both implementations learn it, from one specification.

Said only when it differs from the free total. Deleting is what fragments
a contiguous store, and a disk that has only been appended to has one gap
at the end - so on a healthy disk this is silent, and a line that appears
only when something is wrong is a line somebody reads.

There is no sort on this machine and the entries are in no order, so a
candidate walks the disk: each pass finds the used extent nearest at or
after it, and anything the candidate stands inside pushes it to the far
end and starts the pass again. The same trick allocating uses. So it
costs a pass per gap rather than per file - nearly nothing on a disk with
one gap, more the more fragmented the disk is, which is the right way
round.

holes.img is six files with the second and fourth deleted, because no
other disk here can show any of this: none of them has ever had anything
deleted from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 18:19:04 -04:00
AnachronautandClaude Opus 5 2ad8edf9bc A program can see a directory
Every file service took a name a program already knew - read it, save it,
rename it, delete it, ask how big it is - and none of them could find out
what names there are. dir could list only because it lives in the shell
and calls the filesystem directly. So a file manager, a backup, and the
package manager still to come were each unwritable for want of this.

osDirFirst and osDirNext. DP0 says where to put the name and B how much
room, the same bargain osArgument and osWhereAmI offer.

Q ANSWERS THE KIND rather than a yes or no, so one value says both
whether there is an entry and what it is: 0 a file, 1 a directory, 2 a
save that stopped before it committed, 0xFF nothing more. A caller that
only wants names tests for 0xFF and ignores the rest.

The size is deliberately not in it. A walk hands back a name, and a
program that wants the size asks osFileInfo about that name - the
alternative being a record in memory whose shape both sides have to agree
on, which services.asm went out of its way to avoid for file sizes.

Walk.asm is the first program that can see a directory, and it asks
osFileInfo about each entry BETWEEN two steps of the walk. That is the
hazard rather than decoration: where a walk has got to and where the last
file asked about lives are both held by the system, and two things
sharing one position would show as a listing that stopped early or said a
name twice. Then the same walk in /Apps, since one that only ever ran at
the root would not have proved it walks where you are.

A directory is not asked about at all. osFileInfo answers for one
perfectly well and says nought blocks, which is true and reads as a size
- and nought is a size a file can genuinely have.

The lint baseline moves by one. The kind is decided by a chain of bit
tests in the shape dir already uses five hundred lines away, and arms of
a comparison chain each loading the same variable are the case this
project's own rule says not to collapse: the repetition is what lets a
new arm be dropped in anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 17:55:41 -04:00
AnachronautandClaude Opus 5 b83ba5bf7a A colour reaches a terminal as well as the screen
The console's attribute has always meant something to the screen and
nothing to the serial line: its low nibble picks one of sixteen ink and
paper pairs, and only videoPutCell ever read it. So the fault screen's
red was red in the window and grey down the wire, and Examples/colours
printed " ordinary  highlighted " with nothing to tell them apart.

It is said in ANSI now, on the same terms the cursor is said in: a
register write only marks it and the next character sends it, so setting
a scheme and printing nothing says nothing, and setting the same scheme
twice costs one sequence rather than two. Only the scheme nibble crosses
- the page bits say which tiles a cell draws from, which is a fact about
the screen's own art.

THE ORDER WAS ALREADY RIGHT, which is worth saying because it looks like
a borrowing and is not. Both sets enumerate a three-bit colour, red green
blue counted in binary: one is red in both, three is yellow in both, six
is cyan in both. The same arithmetic done twice, forty years apart. The
one place they differ is slot 0, and that difference is forced - this
screen is ink on black, so ink cannot be black, and slot 0 is grey where
ANSI's is black.

Every sequence begins with a reset, so going from bank 8 to bank 1 does
not write red on the grey paper bank 8 left behind.

Scheme 0 is a bare reset rather than grey on black, and a terminal is
assumed to start plain - so a machine that never asks for a colour says
nothing at all, and one that does put the terminal back on its way out.
Two ways out, because the two endings have different rules: stopping on
purpose goes through stdio, since atexit runs BEFORE the buffer is
flushed and a reset written to the file descriptor would arrive in front
of the text it is meant to follow. Dying on a signal writes the four
bytes directly and accepts that the buffer may be lost.

colourTest walks all sixteen and then halts WITH ONE STILL SET, so the
recording shows the reset after the halt line rather than before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 17:24:44 -04:00
AnachronautandClaude Opus 5 94bdf71356 Lunar Porter moves into a directory of its own
The first application with things of its own, and the first customer for
all three rungs before it at once.

  /Apps/Lander                      a launcher
  /Packages/app.Lander/Lander.sbx   the program
  /Packages/app.Lander/splash.tune  its tune

Typing "Lander" starts the launcher, which needs no new rule: the shell
runs what it reads, so a launcher beginning "#!" and Say.sbx beginning
"SBEX" go down the same road and neither the shell nor the person has to
know which kind of thing they started. It passes on what it was told with
$args, and says #quiet, because a launcher is machinery and not
narration. The game then asks where it came from and joins its tune's
name to that, so nothing anywhere names /splash.tune.

Every recorded Lander test still types just "Lander" and knows nothing
about any of this, which is exactly the claim.

---- Why the directory is not in /Apps ----

A launcher and a directory of the same name cannot both be there; SBFS
refuses the second. And /Apps is the directory the shell walks for every
word it does not know and Tab walks for every first word, so doubling
what is in it is a cost on the path that runs most.

The "app." goes in FRONT rather than behind because Tab matches the start
of a name: a prefix is a namespace and a suffix is a collision. Copy.app
beside Copy.sbx makes "Copy" and Tab complete to the shared "Copy." and
hand you a broken word.

The launcher names drive 0, so a game started from a disk of your own is
looked for where the game is rather than where you are - its lines run on
your disk, which is what makes everything else in a script work.

/lander.state stays at the root. A saved position belongs to whoever
saved it and is found where they are standing; a tune belongs to the
program and is found beside it. That pair is the whole distinction.

---- And what it cost ----

Two hundred thousand cycles, about twelve frames, between the machine
starting and the game drawing: a script opened, a deeper path walked. Six
video captures moved out by that much. The drift bar needed something
else - its pad is counted from the MACHINE starting rather than the game,
so the burn is now twelve frames shorter from the game's point of view,
and the recording holds the button longer instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 15:55:19 -04:00
AnachronautandClaude Opus 5 2defbb49e2 A program can ask where it came from
SWI osWhereAmI hands back the path the program was loaded from, on the
same terms as osArgument, and Libraries/path.asm joins a name to the
place another thing is in. Between them an application can find its own
assets: ask where you are, then pathBeside that and the file's name.

The answer FOLLOWS THE PROGRAM AND NOT THE PERSON, which is the whole
point and the reason the working directory could not serve. A program's
assets are relative to the program and its arguments are relative to
whoever ran it, and cwd can only be one of them - setting it to the
program's own would mean "Play mytune.tune", typed by somebody in their
own directory, looked in Play's.

It is made absolute before the program starts, because the path the
search settled on may be a bare name: a program found where somebody was
standing is named by the word that was typed, and a bare name means the
working directory - which a program is entitled to move out of. Worked
out once, at the start, since where a program came from is a fact about
its start and cannot change afterwards.

Joining is a LIBRARY and not a service. A service that opened a file
relative to the program would need a twin for every file operation there
is - read, save, info, block, start, write, done, delete, rename - while
one service handing back a path composes with all of them.

---- And the root's own path was "//" ----

Found by the first caller that asks. shellPath prepends a separator in
front of whatever string it is given, so being handed the separator
itself wrote two of them. Nothing saw it while the only caller was the
prompt, which asks where it is only when that is not the root. It is
handed an empty string now, and cosmosWhere runs a program from the root.

Where.sbx exists to be run rather than read, and is on the test disk
twice: at the root, where it is found by the bare word typed, and in
/Apps, where it is found by a path that already says where it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 15:27:57 -04:00
AnachronautandClaude Opus 5 d9ebc76cf5 A script is given words, and keeps them
"do build.sh cosmos.asm" looked for a file called "build.sh cosmos.asm",
because scriptOpen copied the whole rest of the line into the name. So a
script could be told nothing, and a launcher - a script whose entire job
is to hand on what it was told - could not exist.

Now the name is cut off the front and what follows is kept whole. $1 to
$9 are the words of it, walked out on demand, and $args is all of them.
Nothing is stored per parameter, so there is no limit on how many a
script may be handed and no second number to keep in step. Both ways of
starting a script pass them on: "do" and typing the name.

A word that was not given is an error that stops the script, like every
other name this shell does not know. Expanding it to nothing would let a
command run with an argument missing and then report success, which is
what stop-on-failure exists to prevent. $args is always set inside a
script, empty if it was given nothing, so "if same $args" can be asked.

---- And the count that describes the block was already wrong ----

Found while adding a field to it. The state one script keeps for another
is saved by a single copy of a fixed number of bytes, and that number was
71 against a block of 77: six bytes of line position had been added in
the middle of it years after the count was written. So the tail of every
saved script was never saved, and #quiet in a helper stayed behind in the
script that called it - the opposite of what this file's own comment
promises and the README documents.

The unsaved line position turned out not to matter, because a loop keeps
its own copy in the block record. Nothing said so. Three numbers describe
this block and all three now say so in a comment, and cosmosScriptNest
ends on a helper that goes quiet and a caller that must not stay that
way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 15:06:32 -04:00
AnachronautandClaude Opus 5 06af7e7fbb Reading a script must not move the person who started it
A script is fetched a block at a time while its lines run, and its name
is resolved afresh for every block. A name with a drive in front of it
moves the machine to that drive on the way past - sbfsWalk calls sbfsUse
- so a script found in the system's place on drive 0, started by somebody
standing on a disk of their own, ran its lines on the system disk.

Always possible with "do 0:/Apps/setup.sh", and reachable by typing a
name now that the search finds scripts the same three places it finds
programs. The drive is kept across each fetch and put back after it, at
both places a script's name is resolved.

The test has to work for it. A script that fits in one block is read
entirely while it is being opened, and the opening was never the hard
part; and the keep in scriptFill cannot be broken on its own, because
scriptOpen has already written the variable down. So the script on the
disk crosses two block boundaries and moves itself between them: what it
says about where it is standing is 1 before the move and 0 after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 13:23:31 -04:00
AnachronautandClaude Opus 5 fd962f0084 The shell runs what it reads, not what a name ends in
A typed word had ".sbx" pasted on the end of it before anything went
looking, which is why "notes.txt" sent the shell after a notes.txt.sbx
that was never going to exist, and why a script could only be started
with "do". The extension was what made a file reachable by name.

Now the word as typed is asked for first and the word with the extension
on it only after that misses. What comes back is dispatched on what is
inside it: SBEX loads and starts, "#!" is read as lines. The loader
already refuses anything that is not SBEX and the script reader already
refuses anything without the shebang, so the two kinds of runnable file
turn each other away and neither has to know the other exists.

The suffix can only ever be a second guess, so a file that is really
there always beats one that would have to be invented and every program
already on a disk still starts by the short name people type for it. It
costs a second walk of each directory when a word is not found in it.

Tab completion offers a file under the name it actually has, and the
suffix stripper is gone: a first word can now be any file at all, and
offering only the ones ending .sbx would hide the scripts.

The video capture windows moved out a hundred thousand cycles, because
starting Lander now walks four names where it walked two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 13:23:14 -04:00
AnachronautandClaude Opus 5 4e61158b11 The splash stops sounding before it ends, so listen earlier
The check asked whether music was still playing four seconds in, and the
score it listens to ends its every voice on a rest - twelve ticks of
silence that hold the logo up while the voices decay. So it was measuring
exactly the quiet the score asks for and calling a perfect tune a failure.

Three seconds instead, which is still twice the second and a half the
silent path holds the logo for, and which is what the check is actually
about: that this is the tune and not the hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 13:22:56 -04:00
AnachronautandClaude Opus 5 f351ee8844 Every score on the disk, not a named one
The makefile compiled splash.score by name, so intro.score sat beside it
turning into nothing - which is exactly the failure the mirror three
lines above exists to prevent, and its comment says so: a list in a
makefile goes stale the moment somebody adds a file, and what they forgot
is invisible until they go looking for it on the machine.

Found by the user going looking for it on the machine.

Every .score in Programs/Tunes is compiled and put on the disk now, and
every patch in Programs/Sounds is converted first so a score can name any
of them. The source is mirrored to /Source/Tunes with everything else
that was written; the tune goes to the root, where a program looking for
one expects it - which is worth writing down, because the two being in
different places is the thing that sent somebody hunting.

two.score moved to Tests. It is a fixture, it names patches that only
exist in the test build, and it was in the music tree only because that
is where scores were when it was written - which the game disk build
found immediately by failing to compile it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 11:15:47 -04:00
AnachronautandClaude Opus 5 dfc9d9ef7f A score is written and a tune is what the machine reads
The convention, at the user's asking, and it is the one this project
already has everywhere else: a .asm is written and a .sbx or a .bin is
what the machine loads. A .score and a .tune are the same pair one
subject along.

It is not only tidiness. I read the user's splash.tune as a compiled tune
yesterday, dumped its header, and got a tick of seven and a half million
cycles and ninety seven patches out of what was plainly a text file.
Different names make that a thing nobody has to notice.

AND THE SPLASH WAS SILENT ON THE DISK THAT MATTERS. The play disk mirrors
every .asm and puts every app, and nothing on it put a compiled tune - so
make run-cosmos and make run-voyager both booted a Lander that read
/splash.tune, did not find one, and held the logo in silence. Only the
test disk had it, because I had added it there and stopped.

The makefile now compiles Programs/Tunes/splash.score with TuneC and puts
the result on the disk, and the mirror's prerequisite list learned about
.score files so that changing the music rebuilds the disk. That is the
same failure the mirror was built for, in a file type the mirror did not
know about yet: tune.asm went into Examples once, the image was not
remade, and it was simply not there.

Measured on the real disk: music from 0.9 s to 9.6 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 10:24:39 -04:00
AnachronautandClaude Opus 5 00b31b88af Lander opens on a splash, with music
The first customer for the player outside the program it was pulled out
of, and the argument for doing it: a splash needs no resident player at
all. Nothing else is happening while the logo is up, so Lander owns the
timer and all four channels exactly as Play does. Music UNDER a running
game is still deferred, and still the harder problem.

It polls the timer rather than being interrupted by it. Lander has no
vector segment and waits for the screen by reading port 0x30, so it waits
for a beat by reading port 0x50 - the same shape, and it brings no
handler that would have to be taken away before the game starts.

The tune is read off the disk. A missing one means the logo and silence
and the game starts anyway, the way a missing /lander.state means the
defaults stand.

FOUR THINGS THIS COST, each found by running it:

A subroutine cannot answer in A. CALL saves and restores it, so splashSkip
handed its caller back the A it already had - the channel number of the
last stepVoice - and the splash ended on its first pass whatever anybody
pressed. Q is what survives a RET, which nextRandom says twenty lines
away and I did not read.

Port 0x3D is how many window rows are SHOWN, and it is two once putGauge
runs and nothing before. A line written to row eleven went somewhere real
and was displayed nowhere.

A nought is not a keypress. It is what a recorded keyboard file holds
while nobody is typing, and a splash that took it for a key is one no
test could ever watch.

And skipping has to be free. Asked after blanking the window and reading
the file, a skipped splash still cost a fifth of a second - enough to
push the thruster test's early capture past the frame it looks at. Asked
first, it costs a pad read.

player.asm no longer asks a caller for Order0 to Order3: the voice records
name NoOrder instead, so a program whose tune comes from a file does not
have to define four order lists it never uses. That was the file case
finding a wart in the contract.

Every other Lander test now skips the splash with a space - a key the
game itself ignores, since it answers to q, z and the arrows - so they go
on testing what they tested. The one in sound.sh presses nothing, which
is what makes it the one that hears the music.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 10:06:20 -04:00
AnachronautandClaude Opus 5 6bb1565dea A tune's tick is the tune's own
useTune took the period out of a file's header and then Play wrote its
own straight over it, so every tune played at a sixteenth note at 120
beats a minute whatever it asked for. A tune with a #Tick of 0d250000
lasted half as long as it said.

Nothing noticed because every fixture in the suite asked for exactly the
tick Play had written into itself. A test that agrees with the bug by
coincidence is not a test, and the way to find out is a fixture that
wants something else - so slow.tune is two.tune with twice the period and
nothing else changed, and it has to last twice as long.

The period now belongs to whoever supplied the tune: useTune sets it from
the header, useBuiltIn sets its own, and the start code writes only the
control byte - which has to come after either of them, because writing
control with the run bit set is what loads the period.

Found while reading Play to see how a splash screen would drive the
player, which is a reminder that the second reader of a piece of code is
worth more than the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 09:37:08 -04:00
AnachronautandClaude Opus 5 87467a7d3a Lander starts a flight where a flight starts
run does not reload, and that is right: run's job is to run the program
that is loaded, which is why it is a separate word from load and why
typing a program's name does both. What follows is that the numbers the
assembler wrote into a Data Segment are LOAD-time values, true once, and
a program that wants them true at every start has to say so itself.

Lander did not. Flying is 0x01 in the data and is only ever cleared, so a
second run began with the loop already over: the program started and
handed the machine straight back. Reproduced by running it, quitting, and
running again - the leftover game keys land at the shell prompt, which is
what a program that never read them looks like.

AND THE TEST FOUND A SECOND ONE, quieter and worse. The terrain seed is a
written number, so a fresh load always walks out the same moon - but
nextRandom moves it, and a second run generated a DIFFERENT moon. Nobody
decided that. A test comparing the whole screen found it without anybody
having had to think of it in advance, which is the argument for comparing
the picture rather than the variables somebody remembered to check.

Only what a flight needs to begin is put back. Anything not on the list
keeps what the last run left it, which is deliberate - state surviving a
run is sometimes exactly what is wanted, and the way to have that is for
the list to be a decision rather than a sweep. The things that are simply
nought are a table of NAMES that the assembler turns into addresses, so
adding a variable that must start empty means adding it there and nowhere
else.

video.sh now runs Lander, quits, runs it again, and requires the same
picture a hundred frames in - the same moon, the same lander in the same
place, the same gauges. break.sh confirms it by taking the seed line back
out: 37,995 bytes of the picture differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 09:02:50 -04:00
AnachronautandClaude Opus 5 b160c66110 The arpeggio's first chord is the note its comment says it is
tune.asm wrote 0x2A four times, labelled "; Gs" each time. 0x2A is F#2.
G# is 0x2C. The file has disagreed with itself since it was written.

It is not a near miss. The progression is Ab, Bb, C - a flat six and a
flat seven resolving home - and the other two chords are right: 0x2E is
A# and 0x30 is C. With F# the first one is a tritone against its own
third and stops being major at all.

Found by the user transcribing the piece into the new tune language,
where writing the notes out again by ear is what made the wrong one
audible. The number was carried across faithfully; the intent was not in
the number.

Nothing churns: it is one value and not a length, so the binary is the
same size and the recordings are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 22:15:58 -04:00
AnachronautandClaude Opus 5 fee2b1ef10 One missing patch says one thing
A patch file that could not be read left the name unregistered, so every
#Voice naming it failed as well, and then the check that a voice has an
instrument failed for each of those. One wrong path produced seven
messages and only the first was worth reading.

A patch that cannot be read is still a patch that was NAMED. It is
registered either way now, with its bytes marked missing, so everything
below resolves the name and says nothing. Nothing is written regardless -
one problem is enough to stop that - so a patch with no bytes never
reaches a file.

The damage from the old behaviour was not the extra lines. It is that a
compiler which says one thing seven ways teaches people to read the last
line, which is the one that matters least.

Checked by counting: one missing patch, three voices using it, and the
count of messages mentioning it has to be one. break.sh confirms it by
putting the old behaviour back on the failure path alone - the first
attempt at that break stopped every tune compiling and the disk build
failed before any test ran, which is break.sh being right about a break
that proved nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 22:09:18 -04:00
AnachronautandClaude Opus 5 fb672d7761 A manual for writing tunes
The fifth document, and the one somebody would want first if they had a
piece of music and this machine. What a tune is, how to write one, what
the compiler refuses and why, how to build and play one, and the bytes it
holds for anything that would rather write one itself.

The last of those matters more than it looks: Tests/maketune.py writes
tunes without going through TuneC, and the suite checks the two agree
byte for byte. A format with two implementations needs a specification
they are both held to rather than one of them being the specification.

The refusals get a table of their own with the reason beside each,
because every one of them is something the PLAYER cannot notice - it has
no names, no lengths, and no way to tell "no starting instrument" from
"instrument nought" once a tune is loaded. Documented as reasons rather
than as rules, so that somebody meeting one knows what it saved them
from.

docs.sh now settles the manual against the compiler both ways: every
directive TuneC takes has a row, and the limits the manual quotes are the
compiler's own #defines. Verified with break.sh - a directive removed
from the manual and a limit raised in the compiler are both caught.

One that was not caught first time and should not have been: removing the
#Use row from one table left it documented in the other, which is the
check being right and my break being wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 21:20:55 -04:00
AnachronautandClaude Opus 5 2808688fa1 TuneC: a written tune becomes the bytes the player reads
The compiler, and the last thing the ladder was waiting for. A tune names
its instruments, writes sequences of notes and durations, and gives each
voice an order list of sequence names - which is where repetition comes
from, since a phrase played four times is written once and named four
times.

  #Tick 0d125000
  #Patch Oboe oboe.patch
  #Voice 0d0 Oboe
  #Sequence Verse
    0d64 0d4  0d67 0d4  0d72 0d8
  #Order 0d0
    Verse Verse Ending

"#" is a directive and ";" is a comment, exactly as in SplitBit assembly
and in the shell's scripts, and numbers are written the way the assembler
writes them. One rule across the machine rather than a third dialect -
and the rule earned itself immediately: the first tune I wrote said
"#Voice 0" and was refused, correctly, for a bare number.

WHAT IT REFUSES IS EVERYTHING THE PLAYER CANNOT NOTICE. The machine has
no names, so it cannot say a sequence does not exist. It has no lengths,
so it cannot say the voices will come apart four bars after the mistake.
A duration of nought is counted down to 255 and held, which sounds like a
hang rather than an error. And by the time a tune is loaded, "no starting
instrument" and "instrument nought" are the same byte - so the user's
ruling, that a voice with a part and no instrument is an error, can only
be kept here.

SoundPatch gains --blob, writing the same table as raw bytes. It stays
the only thing that reads soundThing's JSON: a second program parsing
that format is a second opinion about what a patch means, and the seam
between two opinions is where the LFO bug lived for a fortnight. Patches
are found beside the tune and then on a -I path, the way an include is.

THE TEST IS THAT TWO IMPLEMENTATIONS AGREE. maketune.py lays the fixture
out by hand and TuneC compiles a written source, and the suite checks
they match byte for byte - the discipline SplitDisk and sbfs.asm are held
to, for the same reason: either alone is only self-consistent. The
fixture predates the compiler, so this is also TuneC checked against
something written before it existed. Four more checks cover the four
refusals.

Also: the SoundPatch binary was tracked, alone among the six tools, and
.gitignore lists every other one. Untracked, and TuneC added beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 21:11:36 -04:00
AnachronautandClaude Opus 5 bfc46d982e Play reads a tune from a file
The loader, and the reason it is small: everything in a tune is an offset
from wherever it was put, so taking one means adding the base to two
tables and pointing four voices at their order lists. No sequence is
walked. Nothing inside one is an address to be found and corrected, which
is the difference between a malformed tune that plays wrongly and one
that takes the loader with it.

  "SBTU", version, the tick in cycles, how many patches and sequences,
  offsets to the two tables, an order list each, and the patch each
  voice starts on.

The magic is checked before anything else, because from there on the
loader follows what the offsets name. break.sh shows what that guard is
worth: without it, handing Play a PROGRAM runs the machine away until the
cycle limit, rather than saying it is not a tune.

PatchTable, SequenceTable and VoiceStart became pointers, so the engine
does not care whether a tune came out of a file or was assembled in.
Play's built-in tune now hands over the same three addresses a loaded one
would, in eight lines - which is what keeps the two paths from drifting,
and what made this rung change no scheduler code at all.

Tests/maketune.py lays the fixture out byte by byte. IT IS NOT THE
COMPILER: the sequences and the patches are literal bytes and the only
thing computed is where each piece lands. That is the point - the loader
is checked by something that does not share its idea of the format, which
is the same reason SplitDisk and sbfs.asm share nothing but a
specification.

Both notes in the fixture are number 60, so the octave between them is a
0x80 command loading the second patch out of the file: the header, the
tick, the relocation, an order list and a patch from a file, measured in
one go. Play and the tune live on quiet.img rather than cosmos.img, so a
fixture does not move ten recordings every time it changes size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 20:58:24 -04:00
AnachronautandClaude Opus 5 5d9b39514b The player speaks indices, which is the shape a file has to be
Order lists hold one-byte sequence indices and 0x80 holds a one-byte
patch index. Two tables, PatchTable and SequenceTable, are the only
places an address lives.

THAT IS WHAT MAKES A TUNE LOADABLE WITHOUT WALKING IT. Nothing inside a
sequence or an order list is an address, so putting one in memory means
adding the load address to two arrays and nothing else. The alternative
is a loader that parses every sequence looking for addresses to correct,
which is a loader a malformed file can walk off a cliff.

Doing it now, while the tune is still assembled in, means the file form
and the assembled form are the same shape - so reading a tune from a file
will change no engine code at all. The whole point of the rung.

The patch each voice starts on moved from four calls in a row into
VoiceStart, four declared bytes, and loadStartPatches reads them. A
starting instrument is state and belongs where state goes: the user's
ruling is that a voice with undefined state is an error, prompted by
noticing that a program run a second time starts with the memory the
first run left, because loading is what initialises and running is not.

Order lists also halved in size, which was not the reason but is welcome.

Hand-writing the two tables is exactly the tedium the compiler exists to
remove - every sequence counted into its place, and moving one means
renumbering. Better to feel that here than after a tool has baked the
shape in.

Verified by rendering: bar for bar the same piece. break.sh confirms the
scaling, since an index is doubled to reach a two-byte entry and halving
that step lands on the wrong sequence and fails four checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 20:45:40 -04:00
AnachronautandClaude Opus 5 8029063bdc Sequences, order lists, and a command that changes the instrument
The rung between M3 and M4, and the point of doing it before the format:
the engine learns the tracker's model with the tune still assembled in,
so M4 becomes serialising a thing that exists rather than designing a
thing that does not.

A voice no longer walks one long track. 0xFF now means THIS SEQUENCE
ended, and the voice takes the next address from an order list of its
own. That is where repetition comes from, and it costs no notation: the
bass plays the same sequence in the first bar and the last and it is
written once. Per voice rather than one shared table of four-column rows,
because a voice's order cursor is then a pointer it advances by itself -
the same LDD and STD move everything else here makes. Four columns is how
it reads, not how it is stored.

Sequences also carry COMMANDS, which take no tick: the reader acts and
reads the next event on the same boundary. One is defined, 0x80, which
plays the rest of that voice on another patch, and the other 125 values
are left alone. A patch change reshapes whatever is still ringing on the
voice and nothing can be done about that - a channel has one set of
parameters and a note in its release is using them - so it is a fact
about the hardware and the cure is a rest, which is the composer's.

The engine moved to Libraries/player.asm rather than being copied into
the test a second time, now that it is big enough to drift. Play supplies
the tune and the beat; the library supplies the scheduler, the patch
loader and a voice's state. Verified by rendering: bar for bar identical
across the move.

AND THE TEST FOUND A REAL FLAW IN THE FORMAT, which is the whole argument
for building the reader first. The order list ended with 0x0000, on the
reasoning that no sequence could live below the 0x3000 this program is
based at. True of a loaded program, false of a boot image whose data
starts at zero - so the first test written against it read its own first
sequence as the end of the list and played nothing at all. It is 0xFFFF
now, which mirrors the 0xFF ending a sequence and is impossible
everywhere: a sequence at 0xFF00 or above has fewer bytes left than it
needs. An address is a poor place to hide a flag unless the address is
impossible in every program, not just this one.

One order list in the test now really ends, because otherwise nothing
reached the terminator at all: every voice sat on a long rest and the
break went unnoticed. With it, breaking the test reads garbage past the
end and the counter sums two notes at 781 hertz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 19:54:38 -04:00
AnachronautandClaude Opus 5 f9b08cf7f9 CosmOS lets every voice go when a program stops
A gate is a register on the sound device and only a program can drop one.
A program that has stopped cannot: it is gone. So a note left held
sustained until something else said otherwise, and nothing else did -
one program could leave the machine sounding for as long as it ran, with
nothing the person at it could do.

The shell already puts back the Stack, the vectors, the drive, the
working directory and the screen. This is the same list and the same
argument, and the fault path calls it too, for the stronger version of
the argument: a program that CRASHED is exactly the one that cannot tidy
up after itself, and a machine that will not stop humming is a poor place
to read an error message.

It does not make the device silent at once and does not pretend to.
Dropping a gate RELEASES a note rather than stopping it, so the patch's
release still runs. A bounded tail rather than an endless one is the part
the system can be responsible for without knowing what instrument the
program had built.

Hum exits while holding a note; Pause makes no sound and takes a couple
of million cycles, because the machine stops the moment the shell runs
out of input and a note quietened at that instant leaves no samples
behind to say whether it was. They are on a disk of their own so that a
fixture does not move the ten recordings that quote cosmos.img's listing.

THE CHECK CAUGHT ITSELF PASSING WRONGLY FIRST. Pause was missing from the
disk, the machine halted immediately, and the window that should have
held the tail was past the end of a render a twentieth of a second long -
an empty window's peak is nought, which is indistinguishable from silence.
So the sample count is asserted before anything is read from it. Verified
with break.sh: without the call the note is still ringing at 9869.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 18:54:46 -04:00
AnachronautandClaude Opus 5 7f96499438 Waiting for a key is not the same as time stopping
A console read that had nothing to hand over blocked in read(), and
while it was blocked nothing told the devices any time had gone by. The
whole machine stood still: the clock, the cursor, a disk settling, a note
decaying. Measured, four real seconds of sitting at the prompt bought
zero cycles.

A window has never done this. consoleGatherLine and the key mode loop
both spend a frame and then say so, and the comment on consoleWaited
gives the reason - a display controller blinking a cursor does not stop
because the processor is waiting on a key, and neither does a disk
finishing a read. The terminal path simply never got it.

How it showed was a note left in its release after a program exited.
Frozen mid-decay, it came out a snippet at a time, one per keystroke,
because a keystroke was the only thing that let the machine run at all.

So a terminal read now polls a frame at a time and tells the devices
about each one. Four seconds now buys 3.85 million cycles, which is the
wall clock, which is what the window has always done.

ONLY WHEN THERE IS REALLY A TERMINAL, the same rule the escape sequences
and the erase character follow. A file or a pipe keeps blocking: every
recorded test on this machine is reproducible because emulated time does
not depend on how fast the other end of a pipe is writing, and that must
stay true. The full suite is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 18:54:31 -04:00
AnachronautandClaude Opus 5 41d65d6346 Play waits for its own sound to finish before handing back
The gates all go down on the tick the tracks end, but a gate down is a
note RELEASED and not a note stopped: the oboe and the strings have a
release left to run. Rendered and measured, the tail dies eight tenths of
a second after the last event, and the ring was eight ticks - one second,
which sounds like enough and was two tenths short.

A program that exits with sound still in the air leaves nothing able to
end it: the program is gone and cannot drop a gate. What that becomes
depends on the front end. Behind a window the tail finishes on its own.
On a terminal it does not, because emulated time stops while the machine
blocks on a key - measured, and the reason the leftover sound came out a
snippet per keystroke rather than fading.

Twelve ticks, which is a second and a half and covers the measured tail
with room over. It is a number about THESE FOUR INSTRUMENTS: a patch with
a longer release would want more, and the general answer is for the
system to quieten the device when a program stops, the way it puts the
screen back. That is not built and is worth deciding on rather than
guessing at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 16:13:10 -04:00
AnachronautandClaude Opus 5 ce1c0517aa M3: an instrument for each voice
Play loads a patch per channel before a note is played: an oboe for the
melody, strings under it, a square wave for the bass and a kalimba for
the arpeggio. It reads a count and that many parameter and value pairs -
the format SoundPatch writes - and understands nothing else about them,
which keeps SoundPatch the only thing that knows what soundThing's JSON
means.

FOUR PATCHES CAN BE UP AT ONCE, and that is the whole rung. It is the
first use of d361ea1: before it the LFOs belonged to the whole device, so
whichever patch loaded last owned them for every voice. Kalimba has LFO 0
switched off and the other three have it on, so on the old device this
piece would have played all four parts with the arpeggio's setting -
which is exactly the fault that stopped Lunar Porter's low fuel warning
trilling after the first landing of a run.

The eight patches are the user's, brought over from soundThing; four are
used here and the rest are a palette for the next piece.

The test now asks BOTH VOICES FOR NOTE 60 and gets an octave, because
their patches differ in one parameter and the lower is loaded first. A
device where a patch was global would have the second overwrite the first
and the two would answer in unison. Verified with break.sh: loading both
patches onto one channel flips the octave onto the wrong voice and the
ratio inverts to 0.499.

The test patches carry a release, and that was measured rather than
assumed: without one the first voice's note was still fading into the
second voice's window, which read 634 hertz - not a note, not an octave,
and a reminder that a crossing counter given two notes answers with
neither.

Play.sbx went 379 -> 740 bytes, which is four patches of 47 pairs each,
and is why ten recordings moved. makedisks.sh needed the Sounds include
path that Lander's line already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 16:02:06 -04:00
AnachronautandClaude Opus 5 1687422619 A voice starts pointed at its track, without code to point it
There is no assembler bug. I reported one and was wrong.

A label written in the Data Segment does come out as its address, two
bytes, most significant first - implemented in populateOutputBuffers,
documented in the Assembler Manual, and correct. What misled me was the
test I checked it with: the label was the first thing in an unbased
Data Segment, so its address really was 0x0000, and I read the right
answer as an unfilled placeholder.

So Play was doing at run time what the assembler had already offered to
do at assembly time. The voice records now carry their track labels
directly, which is exactly the shape LDD reads, and nothing relocates on
this machine so the address written is the address it will have. That
takes out startVoice, its four call sites, and the eight SETDs that fed
them: 450 bytes to 379, and the initial state of a voice is now
something you can read rather than something you have to follow the code
to work out.

The comment claiming otherwise is gone from Play.asm, and the same
change is made in fourVoiceTest.

The music is unchanged - bar by bar the render matches to within one per
cent, which is the program loading a shade sooner because it is smaller.
Ten recordings moved for the same reason: 446 to 379, and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 15:24:02 -04:00
AnachronautandClaude Opus 5 79d1e2639b M2: four voices on one clock
Play is the scheduler: one tick, four cursors. Every voice keeps its own
place in its own track and its own count of how much longer the note it
is holding lasts, so a voice playing whole notes and a voice playing
eighths cost the same and never have to know about each other. They
share the tick and nothing else.

That is what makes the tick the smallest subdivision in the piece rather
than a note length - it is the only unit four parts can agree on.

A track is pairs of bytes: what to play, then how many ticks it lasts.
MIDI notes stop at 127, so the top of the byte was free and neither the
rest nor the end marker had to be invented - 0 is a rest and 0xFF ends
the track. Examples/tune.asm spent zero on its end marker and so could
not write a rest at all, which one voice can live with and four cannot:
the silences are what make them separate parts rather than a chord.

The state is four bytes a voice, cursor first because that is what LDD
and STD move - a pointer through a pointer, which is what lets this be a
loop over four voices instead of the same code four times. CALL preserves
A and DP0-2, so a caller says which voice it means in two instructions.

The piece is four bars of C, F, G, C with the parts moving at four
different rates, because that is the thing one channel cannot do. All
four channels get the same instrument, which is exactly what M3 replaces.

fourVoiceTest staggers two voices so each gets a stretch alone: middle C
while the other rests, the octave while the first is silent, then both.
The first two are measured for pitch and the third for level, because
TWO NOTES CANNOT BE ASKED THEIR PITCH - the crossing counter adds them
and answers 785 hertz, which is 262 plus 523 and a fact about nothing.
Verified with break.sh: dropping the channel select trips one check,
pointing both voices at one cursor trips three.

It shares Play's design and not its code, and is smaller - no track ends
in it, so there is no live flag and no count of what is still playing.

Play.sbx on the disk is why ten recordings moved: one added line each,
and the file count with it. Nothing else in them changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 15:03:12 -04:00
AnachronautandClaude Opus 5 1b251fb290 M1: the tune keeps a beat it sets, instead of one it borrowed
tune.asm counted the screen's frames, because until 6b51d63 the frame
was the only regular beat on the machine. It is now driven by the timer.

THE DURATIONS ARE NOT A CONVERSION OF THE OLD ONES. A frame is 16,667
cycles, so a sixteenth note at 120 beats a minute is seven and a half of
them and could not be asked for at all: the arpeggio was written as
seven whole frames, which is six and a half per cent fast, and the rest
of the piece was written to a tempo picked so its subdivisions landed on
whole frames. The tick is now 125,000 cycles, which IS that sixteenth
note, and the durations are what the music wanted in the first place -
an eighth is two ticks, a quarter four, the arpeggio one each.

The player's loop did not change shape: a note table, a wait on each
beat, the gate dropped at the end. Only what it waits on. The handler
stays a bare RETI, because taking the interrupt is what brings the line
down - a program that POLLED the timer would have to read 0x50, and the
comment now says so, since that is the trap.

Three checks in sound.sh, on a program that is the player's loop rather
than the tune: eight ticks last eight periods to within a tenth of a per
cent, and the seventh and eighth notes are measured where the ticks put
them. The middle one is the one that fails on the old timing - at seven
whole frames the boundary slides under the window and it reads 384 Hz,
which is neither note and is what a crossing counter says when given
two. Verified with break.sh by moving the timer's period a byte.

tune.sbx grew 306 -> 318 bytes, which is why ten cosmos recordings moved:
they quote a directory listing. Nothing else in them changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 14:42:45 -04:00
AnachronautandClaude Opus 5 35c6708e46 The shell takes spaces off both ends of a line, not just the front
Tab completion leaves a space after the word it finished, which is right
when another word is coming. When nothing else was coming, that space
stayed on the end of the line and a command taking a file name looked
for one whose name ended in a space:

  load greet.sbx      loaded, starting at 5000
  load greet.sbx      no such file      (the same line, finished with Tab)

Which took most of the good out of completion, since finishing a name
and pressing Return is the whole of what it is for. The existing tab
tests all typed more characters after completing, so none of them ever
submitted a completed line.

lineTrim already took the leading spaces off for indented blocks, so the
trailing ones come off in the same place, on the whole line, rather than
at each of the dozen commands that take a name. Walking back needs no
guard against running off the front: by then the first character cannot
be a space, and a line that was nothing but spaces has already become an
empty one.

CONSEQUENCE WORTH SEEING: echo no longer prints a trailing space it was
given, which is why cosmosTabPath's recording moved. That is the
conventional behaviour and it means what echo is handed is what somebody
would have typed, but it is a real change and not only a bug fix.

cosmosTrim covers a line completed and run straight away, the same
trailing space typed by hand, spaces at both ends at once, and a line of
nothing but spaces, which still has to do nothing rather than fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 12:23:28 -04:00
AnachronautandClaude Opus 5 8631a78229 Backspace, whatever the terminal calls it
CosmOS's line editor looks for 0x08, which is what Voyager's keyboard
sends. A POSIX terminal sends its own erase character instead, and on
most of them that is 0x7F.

It stayed hidden while the terminal was doing the editing: canonical
mode consumes the erase character itself and hands over a finished
line. Key mode turns ICANON off, which is the point of it, so from the
day the shell started editing its own line - 7360374 - Backspace worked
in Voyager and did nothing at all in the console-only emulator. The
Programming Manual already claimed the console normalised Backspace on a
terminal the way it normalises the arrow keys; the code did not.

The erase character is read from the terminal's own VERASE rather than
assumed to be 0x7F, because some terminals are set to 0x08 and a person
who has moved their erase key has said where it is. Forward Delete is
untouched and stays 0x86: two keys, two values.

Only when standard input really is a terminal. A file or a pipe holding
0x7F holds a byte somebody wrote, not a key somebody pressed.

Three checks in terminal.sh under a pseudo-terminal whose VERASE is set
on the slave side: erase at 0x7F arrives as Backspace, erase at 0x08
still does, and a 0x7F read from a file is left alone. The last one is
what fails on the obvious wrong fix of translating 0x7F unconditionally,
which was verified with break.sh along with the fix's removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 11:56:17 -04:00
AnachronautandClaude Opus 5 019c93a587 The console draws a tab instead of dropping it
consoleDraw gave meanings to newline, carriage return and backspace and
dropped every other byte below the first glyph. A tab was one of those,
so it left no mark on the screen at all - while the same byte went down
the serial line, where a host terminal laid it out perfectly.

That is why a tab separated file read correctly and displayed wrongly.
Type and More were never at fault: they hand the file's bytes to the
console unchanged, and the console is where the tabs stopped. An
assembler symbol table came out with its fields run together.

A tab now moves the cursor to the next stop, eight columns apart, and
wraps when the next stop would reach or pass the last column - which is
what an ordinary character does at the edge, rather than a rule only
tabs obey. It MOVES rather than writing spaces, the way a terminal does:
a carriage return followed by a tab steps over what is on the line and
leaves it.

Kept in the console rather than expanded by Type, More, and every future
program that prints text.

Three checks in video.sh, each of which fails on a different mistake: a
tab renders the same screen as the spaces it stands for, one that runs
off the edge renders the same screen as a newline, and sixteen letters
tabbed across still have their ink - which is the one that fails if a
tab is implemented by writing spaces. Verified with break.sh both ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 11:55:23 -04:00
AnachronautandClaude Opus 5 7dca141aae The vector's number goes beside its kind, not at the end of the line
Kind and Number together are what names a thing: Vector 16 and Device 32
are identifiers in a way that Vector on its own is not. Everything after
them - where it lives, what it is called, where it was written - says
something about it rather than naming it, so with the number at the far
end a line opened with a bare kind and closed with the fact that would
have told you what you were reading.

Fields are now Kind, Number, Address, Name, File, Line. A label still
carries a dash where its number would be, which reads as "this kind is
not numbered" rather than as a field that went missing.

Manual and the docs check follow. Verified with break.sh by swapping the
number and the address back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 11:03:11 -04:00
AnachronautandClaude Opus 5 f3d8985bc4 Vectors in the symbol table, with both of the numbers they have
A vector is the one thing about a program that nothing else can tell
you. A pinned vector has its number in the source that pinned it, but a
vector the assembler numbered has that number nowhere at all - not in
the source, not in the binary in any form a reader can find. Until now
there was no way to learn that a vector became number 64.

It also cost two hops to follow by hand. The name in "SWI osPrintString"
is not the name of the routine that implements it, so finding the code
meant searching for the vector, reading the handler's name off the
Vector Segment, and searching again. A vector row now names the handler
and gives the line the two were tied together on.

Both numbers, at the user's asking, because neither can be worked out
from the other without knowing which table the vector is in: the Number
is what a program writes and the machine dispatches on, the Address is
where the handler's address is stored, base plus twice the number. The
slot is computed with the same expression the loader is given, so what
the table says and what gets written there cannot drift apart. A vector
a program only declares is listed too - that is how a program says which
vectors it calls, and how two programs can be checked against each other
for agreeing about a number.

A device has no name of its own, being named by the port it is plugged
into, so it is listed under its handler.

The first field is now Kind rather than Memory, because Vector and
Device are not memories. Sorted Program, Data, Vector, Device.

docs.sh checks the six fields against the manual and against real dumps
of two programs - Keys, a loadable program with all four kinds, and
cosmos, a boot image whose segments both start at zero. It now also
checks that a row's name really appears on the line the row names, which
is what catches the string-newline bug fixed in ca6c8ca coming back.
Verified with break.sh four ways: wrong slot arithmetic, vectors
dropped, a field renamed in the manual, and that bug reintroduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 10:51:50 -04:00
AnachronautandClaude Opus 5 ca6c8ca6ad A newline inside a string is still a newline
readToken consumed a string literal character by character and counted
none of the newlines in it, so every line number after a multi-line
string was short by one - and by one more for each one after that.

cosmos.asm has seven such strings spanning thirteen lines, so by the end
of the file the assembler was reporting labels thirteen lines early.
That was never only a cosmetic problem: the same counter is what every
error message names, so an error anywhere after the help text was
pointing at somebody else's code, thirteen lines away, with no sign that
it was doing so.

Found because the symbol table started publishing line numbers, which
made a wrong one something you could look at. Verified against all 797
of cosmos.asm's labels, every one of which now names the line it is
actually on. The check that keeps it that way comes with the next
commit, which is what gives it something to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 10:51:36 -04:00
AnachronautandClaude Opus 5 3527812c41 A symbol table says which memory, and where the name was written
The dump was an address and a name. Both of the questions it gets asked
were only half answered.

"What is at this address" was ambiguous, because Program and Data are
separate memories and an address alone does not say which one. That is
easy to miss in a loadable program, where the segments are usually based
far apart - and immediate in a boot image, where both start at zero:
replCalculator has a Program 0003 and a Data 0003 and the old file
printed both as "0003 <name>".

"Where is this defined" was not answered at all, and it is the one that
matters more as a program grows. A name defined once and called in forty
places is hard to find by searching. Lander's table names five files
besides its own; CosmOS and its libraries define over a thousand names
across a dozen.

So: memory, address, name, file, line, separated by tabs, sorted by
memory and then address with Program first. Tabs because that makes it a
table cut, awk and sort already read, and no heading line because
nothing should have to know to skip one. Everything needed was already
being passed to addLabel and thrown away; the file name points at the
copy the include list owns, which outlives the label table.

The manual describes the five fields, and docs.sh now settles that
description against a real dump - the shape, not the values, so that an
example cannot go stale and turn editing a program into editing a
manual. Verified with break.sh three ways: a reordered field, a dropped
field, and a field renamed in the manual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 10:21:06 -04:00
AnachronautandClaude Opus 5 7a55cfe151 Say that an option's file is one the assembler writes, and check we said it
-S was added without a row in the Assembler Manual, and the usage it
printed listed a bare "-S <file>" with no long name and no statement of
what the file is for. That is not merely incomplete, it is misleading:
"-S <file>" reads just as naturally as "dump the symbols of <file>", and
asking for it that way hands the source to -S, leaves nothing positional
behind it, and is answered with "No source file specified" on a command
line that plainly names a source. The error described the hole the
mistake left and hid the mistake.

So the usage now prints the long names, says outright that every <file>
is a path it writes and the source is the last argument on its own, and
ends with a whole example command. When the source is missing and a
file-taking option was given, the error says which options take a path
to write. The manual gains the -S row it never had, a warning in the
same words, and a sentence on what a symbol dump is for.

Documenting it twice is how it went wrong once, so docs.sh now settles
both against getopt's own option table: every option the assembler takes
has a row in the manual and a line in its own usage. Verified with
break.sh against the manual row and the usage line separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 09:40:21 -04:00
AnachronautandClaude Opus 5 cdee9acfae break.sh believes a suite's exit status, not the shape of its output
It decided whether a suite noticed by grepping its output for a
"N passed, M failed" summary line. That is right for the suites that
print one, since they print it only when something failed - and
impossible for the three that never print one at all. docs, terminal
and voyager report in their own words, so a break any of them caught
loudly was reported as "NOTHING CAUGHT THE BREAK".

That is the one wrong answer the tool exists never to give, and it was
turning up in a third place: the header already tells the story of the
first two. It made the whole docs suite unverifiable by the harness the
project uses to decide whether a check is worth having.

Every suite already exits nonzero when it fails, so that is the signal
now. The summary line is still printed where a suite keeps a count, and
the suite's own last line stands in where it does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 09:40:10 -04:00
Anachronaut d361ea1e46 An LFO belongs to its channel, not to the whole device
The two LFOs lived in the Synth, so four channels shared them and whichever patch
loaded last owned them for every voice at once. A sound with its LFO switched off
silenced the trill under a sound that was still playing - which is what made Lunar
Porter's low fuel warning intermittent: the first landing, docking or crash of a run
took its trill away, and it was right again next time the machine started.

The engine fix went upstream to soundThing and has come back. synth.c and synth.h
are re-vendored at 71e3cb2, character for character bar the ASCII transliteration,
and now carry two changes: the LFOs moved into the Voice, and synthSyncVoices
carries a free LFO's cycle down alongside its rate. That second hunk does nothing
here - it only matters to a caller that syncs voices, and this device never does,
because syncing would flatten four channels into one instrument. It is taken so the
vendored file stays identical in both trees, and it is commented as such.

Upstream also found a bug in the original patch, in patchLoad, which is soundThing's
own file and does not travel.

Downstream the LFO parameter groups 0x60 and 0x70 now read the selected channel like
every parameter beside them, so an LFO written to one channel is inaudible on the
other three. Everything else about the device is unchanged.

Lunar Porter keeps loading each patch immediately before its note, but for the
smaller reason that now applies: the bang and the latch share channel three, and a
channel used by two sounds has to be told which of them it is about to be. The
comment that said otherwise, and the manual's warning about sharing, are rewritten
as history rather than as a caveat.

Tests/sound.sh's shared-LFO check is inverted to assert the fixed behaviour, with a
third leg added: after proving another channel's patch leaves this one alone, it
switches this channel's OWN LFO off and requires the pitch to move. Without that,
both checks would pass on a device where writing an LFO did nothing at all. Routing
either group back to voice 0 is caught.

Cost, measured: four channels sounding continuously for 400 seconds of audio takes
5.0 s of wall clock against 4.59 s before, about 9% of total emulator time. Half of
that is wasted on voices that cannot sound, since VOICE_COUNT is 8 and there are
four channels; recovering it would mean diverging the vendored file, which is not
worth it at this price.
2026-09-04 22:20:26 -04:00
Anachronaut 3ca5f193e6 The warning's trill, and why it only sometimes came out
An LFO belongs to the DEVICE and not to a channel. There are two of them
against four voices, and a patch carries LFO settings the way it carries
everything else - so whichever patch was loaded last owns both of them,
for every voice at once.

The warning's trill is a saw LFO on the pitch. The patches on channel
three, the bang and the latch, carry an LFO that is switched off, and
they load at the moment they are used. So the first landing, docking or
crash of a run took the trill away and left a plain tone, and it was
right again next time the machine started. Correct until something
unrelated plays is the worst shape a fault can have.

The same thing had already happened silently at startup: the instruments
were set up in order, so the warning's LFO settings, written last, sat on
top of the rumble's and the rumble never had its own at all.

So nothing is set up once any more. Each sound loads its patch
immediately before its note - forty-odd writes at a moment already making
a sound - and is then whatever its patch says, whatever played before it.
Measured after a landing: 1056, 660, 516 hertz, then up to 1698 and down
again. Two sweeps of the saw, which is the trill.

What it does not fix, because it cannot: two sounds overlapping still
share the LFOs, so a warning going off mid-burn re-tunes the rumble for
as long as it lasts. With two between four that is the device.

Three checks at the device level, where the trap can be stated exactly: a
routed LFO bends a pitch (184 Hz against the 262 the note asked for),
another channel's patch takes it away (272, the note itself), and saying
it again gets it back (184). Written up in the Programming Manual beside
the LFO mode, since the next program to want two sounds will meet it too.
2026-09-04 19:44:02 -04:00
Anachronaut 5e85356245 Every thruster that catches pops, not only the first
A pilot already burning upwards who then adds a sideways thruster has lit
an engine, and that is what an engine lighting sounds like. So what is
watched is now the SET of thrusters rather than whether any of them is
lit: the bits on now that were not on before, which is an exclusive or
and an and and no comparison at all.

The rumble still asks the old question, because it is the right question
for it - struck when the first lights and not again until every one has
gone out. Restriking it when a second joins would start its attack over,
which is a stutter rather than an engine, so it keeps a flag of its own.

Pitches are as tuned by ear: 36 for the pop, 60 for the rumble.

Checked by two flights of the same length, one thruster held throughout
against one that gains a second in the middle. The rumble is identical in
both, so the whole of the difference is the extra pop: peak 11,452 against
15,540, and the recordings diverge at 3.19 seconds, which is the frame the
second thruster lights.
2026-09-04 18:30:17 -04:00
Anachronaut 388faafd04 A thruster you can hear, and a latch on arriving and leaving
Three patches, and the last of the sounds that were asked for.

The thruster bangs when it lights and rumbles while it burns. The rumble
is this game's first HELD note: its gate goes down when a thruster lights
and does not come up until every one is out. Only the edges matter - a
rumble restruck every frame would never get past its own attack, and a
bang struck every frame is a buzz - and the condition is the flames':
a held button with a dry tank is a pilot doing nothing.

Arriving latches two notes quickly. Middle C then the C above for taking
hold of the station, the same pair reversed for letting go, and two
octaves lower for the ground - the same shape in a different register,
because setting down and taking hold are the same kind of event. The
second note is PENDING rather than played: at an undocking the pilot is
mid-burn, and stopping the world for an eighth of a second to fit a note
in would be felt as the controls sticking. Where the game is stopping
anyway, runPend simply pumps it out.

Four channels for five sounds. The bang and the latch share one, because
a lander arriving either arrives or does not, and a crash ends the run.
That leaves the thruster its two, which it needs: a held note struck on
the same channel as the ignition bang would cut the bang off at the
moment it was meant to be heard.

---- A held note outlives the loop that was holding it ----

Landing while the thruster was still down ended the flying and then
waited to be told the message had been read, so the frame that would have
noticed the button coming up never ran. The engine roared under the
verdict and went on roaring until the machine stopped. Anything that
stops to wait hushes it now, and forgets last frame with it, so a thruster
still held when the waiting ends counts as lighting again.

---- Three checks that had to be rebuilt around the new noise ----

A landing is not silent any more, so the crash is measured against the
second BEFORE it rather than against a quiet landing. The dust samples
moved eight frames later, because the latch plays first. And the warning
check lost its measure twice: counting bursts could not tell a beep from
a nag, and measuring total length stopped working the day the thruster
got a rumble. It burns, stops, and listens AFTER - warned once there is
nothing left, nagging the last beep is still fading. Nought against 5,679.
2026-09-04 17:34:16 -04:00
Anachronaut 6fe898b5fc A quarter-tank warning that says it once, and a gauge that keeps saying it
Two halves of the same number. The warning is the moment it happened and
the colour is how things stand: it fires on the way down through a
quarter of a tank and then holds its peace, and the gauge stays red until
a base fills the lander up, which also allows the warning again.

Once, because a lander is at its most careful in the last few seconds
before it touches, and something repeating in its ear through that is not
a warning, it is a distraction.

Checked where the fuel actually moves rather than once a frame - the tank
only changes in takeFuel and payFuel, so there is nowhere else it can
cross a threshold.

Channel two, with the crash on three. Separate channels rather than one
reused, because a crash while the warning is still sounding should not cut
it off, and on a machine with four voices there is no reason to be clever.

Half and a tenth are the obvious next thresholds and are deliberately not
here: one is enough to find out whether being told at all is welcome.

---- And the crash sound is the one that was designed for it ----

Crash.json, which carries voice_gate itself, so the program's own trigger
write is gone - it would have masked a deliberate choice rather than
backing one up. The note moved from 24 to 60 by ear, and the comment
saying it was low went with it.

---- A check that could not tell a beep from a nag ----

Counting bursts of sound cannot: without the latch the warning fires
every tick the tank drops, far faster than the sound decays, so the beeps
run into each other and a burst counter sees one long burst either way.
It measures the LENGTH now - 26,944 samples against 490,348 - which is
the difference between six tenths of a second and ten seconds of it.
2026-09-04 16:45:26 -04:00
Anachronaut de765267c5 Lunar Porter's crash is a designed sound now, not a guessed one
Crash1.json, made in soundThing and converted by SoundPatch. Two noise
oscillators fifteen cents apart, a filter opened 11.6 kHz by the
modulation envelope, and the LEVEL shaped by that same envelope rather
than by the amplitude one - which is exactly the freedom the routed level
was for, and not a thing the engine could express a week ago.

It peaks at 10,501 and is gone in about six tenths of a second, inside
the eight tenths the pieces are in the air. The guessed one wandered
between 1600 and 8200 and sounded like a drain.

The patch predates the gate field, so the program's own trigger write is
what makes it a one-shot - which is the argument for having put it there
rather than leaving it to the patch. LowFuel.json is here too, saved from
the newer soundThing with the gate and LFO mode in it, and is not wired
to anything yet.

Both JSONs live beside the tables they produce. The build has never
needed soundThing and still does not, but a sound can now be regenerated,
or simply read, without going to find the editor first.
2026-09-04 16:13:44 -04:00
Anachronaut b2ff8d64e5 Fold soundThing's changes back down, and expose the two new switches
The three changes that went up came back as part of soundThing, along
with two more that they made possible. The engine here is now b73e5c0
character for character, except that em-dashes and arrows in comments are
written as ASCII because this tree is ASCII only - a local rule, not an
improvement, and not sent up.

So synth.h's "what was changed" list is gone. There is nothing to list:
what has to be kept current is only that if either copy changes, the
other one has to be told.

---- What came back ----

A VOICE CAN END ITSELF. Naming the level's source said what shapes a
voice; nothing said what ends one, so the only thing that could ever
finish one was a key coming up. A game is nearly all one-shots and not
one of them wants its length decided by how long a note was held. Exposed
as parameter 0x51: 0 gated, 1 triggered.

AND A ONE-SHOT IS THE SAME ONE-SHOT TWICE. A triggered voice re-arms its
oscillators, and an LFO can be told to start over with each voice -
parameter 3 of either LFO. Both halves are needed and the check proves
it: with the LFO left free, two triggered hits still differ.

Their note warned that whatever applies a patch to a channel has to set
these or they hold synthInit's defaults. Checked: Voyager never calls
synthSyncVoices, so their 0001 is a no-op here as they predicted, and
nothing reaches into an LFO's phase, so the struct split is safe.

---- What it is for ----

Lander's crash is a triggered voice now, so boomOff is gone. Nothing has
to remember to end a bang.

SoundPatch learnt voice_levelSource, voice_gate and lfo<N>_mode, which
the new soundThing writes - without that it would have refused every
patch saved from it, since an unknown field stops the tool on purpose. A
patch from before those fields still converts, and says in its own
comments that it predates the level routing.

Three checks, each seen to fail on its own break: a gated voice still
sounding with nothing holding it, a triggered one down to nothing with no
gate ever dropped, and two hits identical sample for sample.

One test bug worth keeping: the first version of the repeatability check
struck the second note while the first was still ringing, so what it
found and compared as "the second hit" was a point in the middle of the
first one's tail. It now looks for sound after SILENCE rather than sound
after an offset.
2026-09-04 16:02:40 -04:00
Anachronaut a8b6b09a59 SoundPatch: design a sound where it can be heard, then convert it
The bang was guessed at directly in bytes and came out as a low gurgle,
which is what guessing at bytes gets you: a cutoff of 40 looks small and
is 57 Hz, so the filter sweep ended almost shut. Voyager's sound device
is soundThing's voice engine with the editor taken off, so a patch
designed in soundThing - where there is a screen, a keyboard and a pair
of ears - makes the same sound here. What differs is only how it arrives.

SoundPatch converts one into the other. Every parameter the device takes
is a documented function of a natural value and all of them invert: times
are squared into four seconds, cutoff and rate are exponential, depths
and detune are centred on 128. It writes a table of a count and that many
parameter and value pairs, and playPatch hands it to the device - so
every sound after this costs a table and a call rather than forty lines
of its own.

Two things the conversion has to say out loud. soundThing has no field
for the level routing, because there it is always the amplitude envelope,
so the table says so explicitly - a channel keeps its patch between notes
and a leftover from a previous one would otherwise be carried in. And a
field the tool does not recognise STOPS it: a patch format that has moved
on would otherwise produce a table that quietly means something else.

THE BUILD DOES NOT DEPEND ON IT. soundThing lives in its own repository
and is not needed to build anything here; the tables are checked in and
the tool is for when a sound is being changed.

Lander's crash now plays Kick808 as a stand-in until a bang is designed
for it, and the difference is the point: the hand-guessed patch wandered
between 1600 and 8200 for eight tenths of a second, and this decays
3492, 2743, 2037, 1515, 1040, 614, 87, nothing.

The docs check caught the tool count in two manuals, which is what it is
for.
2026-09-04 13:00:18 -04:00
Anachronaut 9ae59bfccb A bang for the crash, which is this game's first sound
Noise through a low pass that the modulation envelope shuts as the level
falls, so the bright part is only at the front of it: a boom rather than
a hiss. Noise because every other waveform here has a pitch, and a
pitched bang is a note.

The instrument is built at startup the way the tiles are, and a crash
only says "this channel, this note". That is what the selector and value
registers are for - a patch is twenty odd writes and a note is two - and
it is the shape every sound after this one should take.

CHANNEL THREE. There are four, and effects count down from the top so
that music, if it ever arrives, can take nought and count up and the two
never have to negotiate. This is also the first program to drive the
sound device while also doing something else; the only other customer is
the patch editor, whose whole job is the device.

---- A byte of envelope is not seconds ----

It is squared and scaled to four of them, so the decay first written here
was 200 - which is two and a half seconds. Over the eight tenths of a
second the pieces are in the air that is not a bang fading, it is the
FRONT THIRD of one, and it both sounded and measured as a flat wash of
noise. Ninety is about half a second and it fades to silence with time to
spare.

Two checks, each seen to fail on its own break. The first is against
SILENCE - a landing in the same conditions makes no sound at all, so it
is measuring the crash and not the machine humming - and the second is
that it is louder in its first half than its second, which is the
difference the decay was getting wrong.
2026-09-04 12:35:29 -04:00
Anachronaut 0264b19a3d Dust on landing, gas on letting go, and a lander that stays let go
One particle system, three uses now: a lander coming apart, dust kicked
up by a landing, and gas out of a docking port on release. Where they
start, how fast they go, what colour they are and how long they last are
arguments; everything else is shared.

Dust goes sideways and UP off the lander's feet, because that is where
kicked dust goes and there is ground in the way of the rest of it. Gas
goes evenly in every direction, because nothing is in the way of a
docking port. Neither happens on docking - a dock is a catch and not a
touchdown, and there is nothing under it to kick.

---- Ticked from the frame loop, not run in place ----

The explosion can afford to stop the world; there is nothing left to fly.
The undocking puff cannot, because it goes off on the frame a thruster is
pressed, and freezing a quarter of a second exactly then is felt as the
controls sticking. So a burst advances one frame at a time from the main
loop, and the two that can afford to wait just pump that same tick until
the air is clear.

---- And letting go did not let go ----

Which the puff is what found. A docked lander sits EXACTLY one tile under
the station, so releasing upwards moved it towards the station and it
docked again on the very next frame: took the fuel again, said so again,
and waited to be told the message had been read - which reads as the
controls locking up the instant they are used.

It has to get clear now before it can take hold again, and the two
distances have to differ: docking wants one tile, re-arming wants two.
A single distance re-armed on the frame it let go, because a tile is
exactly where it was sitting.

Three checks, each seen to fail on its own break. The last of them
measures HOW FAR the lander has got and not merely that it moved: a
sixteen frame pause on release still leaves it climbing, four rows short
of a free run, so "it moved" would pass for a stall that has been slept
through.
2026-09-04 12:23:31 -04:00
Anachronaut 115efa1fa1 A crash takes the lander apart, instead of just saying so
The verdict used to be the whole of it: a line of text and a lander still
sitting there in one piece, so somebody watching a recording had to read
the words to know what had happened. That is the same problem the flames
were for.

The lander goes, and both flames with it, and six pieces of it leave in a
rough hexagon at its own colour for about a second. Then they go too,
rather than hanging over the words. The world is not running while it
plays: it is a loop of its own, so nothing else in the program has to
know how to be half destroyed.

Yellow, which is the lander's own colour, because it IS the lander - and
it is only free to use because the lander itself is hidden by then. The
check that counts exactly forty pixels of yellow looks at a flying frame.

---- Two mistakes worth keeping ----

The block went in between touchdownCrash and touchdownStop, so a crash
fell into the explosion and returned from there - no verdict, no end of
run, and the lander sitting there being crashed into the ground again
every frame. The linter caught it as a subroutine nothing called walking
into, which is exactly what it was.

And copyWord goes DP0 to DP1, so setting the pieces off from the lander's
position had the pointers the wrong way round: it copied the empty pieces
OVER ShipX. Since that is in the view block, it took the lander's own
column with it, and the one piece that could be seen drifted out of the
top left corner of the screen.

Three checks, each seen to fail on its own break. They measure the SPREAD
and not the count: two of the six leave the top of the screen on the way,
so the count drops from twenty four to sixteen, which is correct and
would make an exact count a check that breaks the day a lander crashes
somewhere else.
2026-09-04 12:06:00 -04:00
Anachronaut c408fc6cf6 Thruster flames, so a watcher can see what the pilot is doing
Every reading on this screen is a number drawn as a bar - how fast
sideways, how fast down, how much sky, how much tank - and all of it says
what is happening TO the lander. None of it says what the pilot is doing
about it, so somebody watching over a shoulder has to read gauges to work
out that a thruster is even lit.

A plume hangs off whichever side the engine is pushing from: under the
lander to lift, over it to retro, and on the far side from the way it is
being pushed sideways, since that is the side the gas leaves. One tile
does up and down, because a vertical flip turns one into the other, and a
second does the sides, because a flip cannot rotate a tile a quarter
turn. Twenty four pixels each, on purpose, so a count of them means
something.

HELD, NOT FIRED. The engine fires one frame in ten, because that is the
tick gravity is applied on, and a flame that honest would be one frame of
light six times a second - a fault lamp, not a rocket. What is drawn is
the button being down, which is the truthful answer to "is the pilot
burning": the tick is how the sum gets done, not what is happening.

An empty tank draws nothing, and nor does the retro thruster on the
ground, because in both cases the button really is doing nothing. The
second of those was a lie the first version told.

Red, and that is by elimination again: white is the ceiling warning, cyan
the landing pads, magenta the instruments, blue the station, yellow the
lander itself - and the lander is counted as exactly forty pixels of
yellow, so a yellow flame would have broken it.

Three checks, each seen to fail on its own break. Two things the fixtures
taught: a lander placed at the world's origin sits BEHIND the two row
window, which reads exactly like a flame that is not drawn; and red has
to be looked for in a box round the lander rather than a column, because
the speed bars go red and one of the four pads is red too.
2026-09-04 11:49:38 -04:00
Anachronaut 418631a221 The orbit check is back, and osFileRead says it reads in blocks
Placing a state retired the reason the orbit check was deleted. Reaching
a given orbit through the controls takes a sustained burn while holding
height, and the phase of that burn against the gravity tick - one frame
in ten - decides whether the thruster is seen at all, so two pad files a
frame apart fly differently. The old check passed against one disk and
failed against another, which is a check measuring the boot time rather
than the physics.

Placed at eighty sideways it climbs to row 51, falls to row 190, and
climbs again to row 20 - and the turning points are BROAD, tens of pixels
across, so the samples have nothing like the margin problem the old one
had. Three claims: it climbs, it turns over on its own, and it comes
round again no lower than the first time.

Both halves of the mechanic are separately caught. Without the outward
push it sinks and lands and never climbs; without the exchange it climbs
away and never comes back, which is the one way trip the whole thing
exists to prevent.

---- And osFileRead writes whole blocks, which nothing said ----

A disk is read a block at a time, so a sixteen byte file still puts 256
bytes where it is told to. Reserving exactly the file's length writes
over whatever follows - a quiet corruption rather than a refusal, and it
looks like a bug somewhere else entirely. It cost an afternoon here: the
state buffer sat in front of the view tables, so the program read its
state, wiped the numbers every gauge draws from, and left immediately.

Said now in services.asm beside the vector and in the CosmOS manual,
along with the pattern that works: reserve the length rounded up to the
next 256, read into that, and copy the parts wanted where they are wanted.

The orbit fixture also needs its own keyboard file. The shared one holds
a key down every forty eight bytes for the held-thruster check, which
would fly this orbit as well as measure it.
2026-09-04 11:31:33 -04:00
Anachronaut 7e82b64d47 A dock that slides into line, and settles squarely on the port
Two things, and the second is the one that was actually wrong.

The lander is slid into place at half a pixel a frame rather than put
there. A dock is allowed eight pixels out in either direction, so
snapping moved it a whole tile in a single frame - a jump, at the very
moment the player was being told they had been careful. The worst gap
closes in about half a second, which reads as the two of them settling
together.

And it settles SQUARELY now. It used to come to rest four pixels out
however carefully it was flown, because the lander is drawn from half a
screen less half a tile - which is what centres an eight pixel lander on
the middle - while the station was drawn from half a screen exactly, so
its left edge sat where the lander's centre was. Both come off the same
origin now, and a gap of nothing puts one exactly above the other.

stationGap is factored out along the way. Three callers wanted it: the
one that draws the station, the one that decides whether it can be docked
with, and now the one that slides the lander in under it. The sideways
ease goes through that wrapped gap rather than the raw positions, because
a dock made either side of the moon's seam has a raw difference of most
of a moon.

---- And the fixtures moved to frame 400 ----

A dock waits to be told its message has been read, and reading a state
file costs a disk read, so a placed run starts a good deal later than a
plain one. The acknowledgement was at frame 100 and stopped working the
day the state file arrived: the program had not reached the dock yet and
the press went by unheard, which shows up as every sprite missing and
reads like a drawing bug. Everything after it is sampled well clear of
both ends.

Two checks, each seen to fail on its own break - the alignment settles at
4,-8 without the shared origin, and the slide reads 0,-8 the whole way
without the easing. Which of the two axes each one actually watches is
written down beside them, because it is not the one you would guess.
2026-09-04 11:23:54 -04:00
Anachronaut 1928275f87 A state can be placed, which four things were queued behind
Lander reads sixteen bytes from /lander.state if the disk has one and
starts from those: position, velocity, where the station is, fuel, and
which screen to be in. A disk without the file is the game as it always
was, which is every other flight in the suite.

It exists because some states cannot be flown to. A successful dock needs
the lander alongside the station and matched, and NINETY SIX pad files
failed to get there - not for want of trying, but because climbing spends
sideways speed, so a lander cannot rise while matched and arrives slower
than orbital every time. Reaching it wants two burns and a phase. The
orbit check had already been deleted for the same reason, and the strike
check was flown on a nineteen frame window, which is the sort of fixture
that ends up measuring the boot time rather than the physics.

Binary, because that is what a file is on this machine. A tool to build
one from readable text is a small job for another day; until then the
tests write the bytes with the fields named, which reads plainly enough.

---- The buffer is a whole block, and that is not caution ----

osFileRead lands a file in Data Memory and a file is stored in blocks of
256, so reading sixteen bytes into sixteen bytes of room writes over
whatever follows. It did: the first version put the buffer in front of
the view tables, and the program read its state, wiped the numbers every
gauge draws from, and left immediately - "finished" and back to the
prompt, with nothing on the screen to say why.

Four checks, each seen to fail on its own break: a state file places the
lander (row 192 and twelve cells of gauge, against thirty one with no
file), a matched approach docks and is paid once and not once a frame, it
then rides the station a tile under it, and the same approach unmatched
is a wreck. The flown strike fixture and its narrow window are gone.
2026-09-04 10:51:01 -04:00
Anachronaut 804dd3040e Docking, and a ceiling that is no longer the old screen's edge
The station can be docked with: close enough, and slow enough RELATIVE TO
IT, or it is a wreck. Its speed is orbital speed, which is what the marks
on the drift bar point at, so the instrument for this was on the screen
before there was anything to dock with. A dock pays eighty units of fuel,
once and not once a frame, and holds the lander a tile under the station
until a thruster lets go - checked before the speeds are put back, or a
burn would be wiped on the frame it was made and the lander could never
leave.

---- And the ceiling went up, which is what made it work ----

Sixty four pixels was the whole of the sky a forty column screen had over
the world's origin. It was never a fact about the world, it was a fact
about the view, and the wide one starts twenty four rows higher: a lander
stopped at the old line was stopped a long way short of the top of its own
picture for no reason it could see. It is 192 pixels now, the top of the
wide view.

The station went from four rows up to twelve - about two thirds of the way
from the ground to the ceiling. At four it sat exactly where anything
climbing away from the surface had to pass, and being run down there is
not a hazard, it is a toll: ELEVEN of this suite's flights were being run
down as collateral, including the ceiling check, which has to climb past
it to reach the ceiling at all. Moving both fixed all eleven at once.

The height bar divides by sixty four rather than thirty two, because the
band it describes is 368 pixels now and half a pixel of bar to a pixel of
sky would stand 184 tall and run off the top of the forty column screen it
is drawn beside.

---- What is checked, and what is written down instead ----

The wreck is flown. The fixture is narrow - the window measured 496 to 514
frames of climb and it sits at 505 - and that is said in place, along with
the instruction to re-measure before believing the code is broken. It was
not always narrow: at the old altitude any climb from 135 to 300 frames
struck. Narrow is the right way round, because it means the station is
hard to blunder into.

The successful dock is NOT flown, and ninety six pad files failed to find
it. That is not the search's fault: climbing spends sideways speed, so a
lander cannot rise while matched. It is measured working instead - tank
100 to 180, message up, and the pair holding together one tile apart for
two hundred and forty frames.
2026-09-04 10:37:52 -04:00
Anachronaut 922511c8a7 A retro thruster, at half the strength of the one that lifts
Arresting a rise meant a sideways burn and a wait for the orbit to come
back round. That is how a rendezvous really is flown and it is a lot to
ask of somebody who has not flown one before, so down now makes the
correction directly.

HALF THE STRENGTH, deliberately: one sixteenth a tick against two, which
is exactly gravity's own step. So it can stop a climb and it can hurry a
descent, and it can never turn a landing approach into a crash faster
than simply letting go would - the cheap way out of a mistake stays the
expensive one. Four against eight on a keyboard, which is the same ratio.

It does NOTHING to a lander on the ground, and that guard is load
bearing rather than tidy. touchdown has already had its say and returns
early once a lander is down, so there is nothing underneath to stop it
and no crash to say it happened: measured without the guard, holding it
drives the lander clean off the picture and spends a fifth of the tank
doing it.

---- And the fixture that hid all of that ----

The first version of the landed check passed just as happily with the
guard deleted, and the reason is worth writing down. A lander that has
landed WAITS to be told its message has been read, and the keyboard
fixture pads with NULs, which are not keys. So the program sat in that
loop for ever and the picture was frozen at the moment of touchdown -
every thruster held afterwards did nothing, which reads exactly like a
working guard. The pad now presses A to get past the wait before the
check tests anything, and both the row AND the fuel are read, because an
engine that fired and moved nothing would keep the row and one that moved
the lander for free would keep the fuel.

The altitude bar check upstream leans on that same freeze for its stable
end, and its comment said "stays landed" when what it means is "is not
running any more". Corrected, because that is the sort of comment that
sends the next person looking in the wrong place.
2026-09-03 23:02:19 -04:00
Anachronaut 800f555ffb A station in orbit, going round and not yet dockable
S1 of the station: it exists, it orbits, it wraps, it is drawn. Docking
is deliberately not here - the point of stopping at this rung is to fly
up and find out whether matching a four pixel a frame target feels good
before any rules are written about what happens when you reach it.

IT NEEDS NO PHYSICS OF ITS OWN. A body at 64 sixteenths is exactly what
a circular orbit is under the rules already here: the pull and the swing
cancel at that speed at ANY height, because gravity never falls off and
the moon is a cylinder that does not rotate. So the station is a
position, a constant, and the same fourteen bit wrap the lander uses.
That also means there is no prograde or retrograde to choose - nothing
privileges a direction, which is what makes a second station going the
other way a thing that can exist later.

Four rows above the world's origin: off the top of a forty column screen
and comfortably inside a wide one, so it is somewhere to go that the zoom
is needed to see. It starts half a moon away and a lap is 256 frames, a
little over four seconds, so it has to be found but will not stay lost.

Its column is the middle of the screen plus how far round it is from the
lander, wrapped to fourteen bits - which measures the long way round
whenever it is behind, so anything past the half way point becomes a
negative offset instead. Off the edge needs no test at all: a sprite's X
is signed and sixteen bits, so a station three hundred pixels to the left
is asked for at minus a hundred and forty and the device declines.

Blue, and that is not taste. White is counted to find the ceiling
warning, cyan to find the landing pads, magenta is the instruments and
yellow is the lander. Blue is the one ink no check measures, and picking
a measured one has broken a test twice already.

toPixelsSigned is factored out of showLander, since the station wants the
same sign-extended conversion.

Four checks, each seen to fail on its own break. Measured going round at
244 pixels in sixty frames, off the far side of the moon, and back on the
other edge.
2026-09-03 22:41:28 -04:00
Anachronaut 32559ce872 The room a zoom buys goes to the sky, not to the moon
Zooming out drew fifty rows DOWN from the world's origin, which put
exactly the same sky on the screen as before with twice as much moon
under it. Measured: 47 per cent rock zoomed in and 71 per cent zoomed
out. A zoom that shows you more of the thing you cannot fly through is
not worth a button.

The eighty column screen is fifty rows and the flyable band is thirty -
the ceiling is eight rows above the origin and the deepest valley is
twenty two below it - so the twenty rows a zoom buys have to go
somewhere. They go above. The wide view starts twenty four rows over the
origin, the ground sits near the bottom, and the whole band is on the
screen: 23 per cent rock instead of 71.

Which needed three things. The moon is drawn from row minus twenty four
rather than from nought, because rows above the origin are sky by
definition and because whatever the shell left in them is otherwise still
there. The row origin comes out of the view block like every other screen
number. And the lander's own Y moves with the view, which meant making
toPixels' answer SIGNED at last: it masks to twelve bits, so a lander
above the origin came back as a large positive number rather than a small
negative one - harmless while such a lander was off the picture either
way, and wrong the moment the view moved up to include it.

That is the point of the button. A lander at the ceiling is off the top
of a forty column screen, which is where the orbit lives and why the
altitude bar had to exist; zoomed out it is at row 149 and you can watch
the whole orbit.

Four checks, and the proportion is deliberately not on its own: measured
alone it passes for a moon floating over a void, because pointing the
view back at the origin leaves the rows under the terrain simply never
drawn, and black counts as sky. Both breaks that matter went straight
through it. What catches them is that the ground has to reach the bottom
of the screen and the sky has to be empty - the latter only on a shell
scrolled a hundred and eighteen lines deep, since that is what it takes
to get anything into the rows the wide view moves into.

The tap fixture also moved to frame 100. Fifty rows of moon take longer
to draw than twenty five, and a six frame tap at frame thirty now lands
before the program is reading a controller at all, which reads exactly
like a button that has stopped working.
2026-09-03 22:14:02 -04:00
Anachronaut 437ddf8ebe z zooms too, so a keyboard is not shut out of it
B was the only way to swap the view, and the game is meant to be flyable
without a controller - the arrows fly it when there is no pad. Somebody
without one had no way to zoom at all.

Tested ABOVE the pad test rather than beside the arrows, and that is the
distinction: which way the lander is flown is a question a controller
answers better, so the arrows stand aside for one. How much of the moon
is on the screen is not that kind of question, and a player with a pad
may still have a keyboard in front of them.

No edge to remember here either. The console delivers a key ONCE, which
is the whole difference between a key and a held button.

The check puts the z forty bytes into the keyboard file, because the
console hands over one key a frame: a z two hundred bytes in is a z two
hundred frames away, which is past the end of the capture and reads
exactly like a key that does nothing. It cost a wrong answer first time.
2026-09-03 21:54:12 -04:00
Anachronaut a02d701efe Two zoom levels on a button, which the device already had
Forty columns and eighty are the same map, the same 8x8 cells and the
same engine; only how many of them fit differs. The map is 128 by 128
either way and the moon is exactly 128 columns of it. And the front end
scales whatever it is handed by the largest whole number that fits, so
320 by 200 at four times and 640 by 400 at twice fill the same glass.

So the two modes ARE two zoom levels and nothing in the video device had
to change to get them: forty columns shows under a third of the moon at
twice the size, eighty shows nearly two thirds. Out for the orbit, in for
the landing, and B says which.

What did have to change is every screen coordinate in Lander, because the
middle of the screen is 160 on one and 320 on the other. They now live in
one block that setView copies over from whichever of two tables matches
the mode, so a gauge reads a variable and never has to know which screen
it is on. Several coordinates became sixteen bit on the way, since 620
will not go in a byte. follow already read HalfScreen and showLander
already writes the world position straight through, so the lander itself
needed nothing but its resting column.

The moon is redrawn on a swap because it is filled as many rows deep as
the mode shows, and a moon drawn 25 deep on a screen showing 50 floats
over nothing.

The swap is edge triggered. A pad is LEVEL and not an event, so a view
that swapped while B was down would swap sixty times a second - which is
not a zoom, it is a strobe, and it redraws the whole moon each time. The
check for that holds the button for three hundred frames and requires the
lander to land where a six frame tap left it; with the edge dropped it
ends seventeen pixels adrift, which is the strobe costing it frames.

Four checks, each seen to fail on its own break. The README's Lander
entry also still described the relief orbit that the previous commit
replaced, and now describes the one that is there.
2026-09-03 21:48:32 -04:00
Anachronaut 85029d3b85 An altitude bar, and orbital speed marked on the drift bar
The orbit takes the lander off the top of the screen, and the panel only
worked while the ground was in sight. Both other bars are rates: they say
how fast, and neither says where.

The altitude bar is height above the surface underneath, up the left edge,
half a pixel of bar to a pixel of sky - the flyable band is about 256
pixels and the screen is 200 tall, so pixel for pixel would run off the
top of the very screen it describes. Above the surface rather than above
some fixed line, so it reads NOUGHT the moment the lander is down.

The marks say where 64 sixteenths is. Without one that number is folklore:
a pilot can feel that somewhere around here the falling stops and has no
way to see where. The bar is a pixel a sixteenth from the middle at 160,
so the marks sit at 224 and at the two pixels before 96, adjacent rather
than overlapping - a bar at orbital speed would otherwise hide the thing
it is being measured against.

Both in magenta. Red and green are taken and they MEAN something here,
how fast and whether it can be landed with, and an altitude is neither.
White was the first choice and the ceiling check counts white to find its
warning, so it read a warning that was never up - the suite caught that.
Cyan was the second and it is the colour of a landing pad, which is
checked as whole cells of it.

groundLevel is factored out of restOnSurface, which had the same sum.

Three checks, each seen to fail on its own break. Two flights, because no
one flight holds both ends of the bar well: the climb reads 219 pixels and
the descent lands and then stays landed. They fly on their own keyboard
file - the shared one holds a key down every forty eight bytes for the
held-thruster check, so borrowing it flew the lander from the keyboard and
the controller at once and turned the gentle descent into a crash.
2026-09-03 20:57:16 -04:00
Anachronaut c514d5328e An orbit that comes back round, out of gravity minus the swing
The relief version could never make one. It only ever SUBTRACTED from
gravity, so a lander a little too slow sank for ever and one a little
too fast rose for ever - nothing in it could turn a fall around, because
nothing in it ever pushed up. There was no periapse to have.

Gravity minus the swing outwards has a sign change in it, and that is
the whole mechanic. Below orbital speed the pull wins and the lander
falls; above it the swing wins and the lander climbs; at 64 sixteenths
they cancel and it circles. Falling buys sideways speed and climbing
spends it, so a fall carries the lander past orbital and turns into a
climb, and the climb pays it back and turns into a fall.

The trade is the quarter square multiply, because the rate has to be
the PRODUCT of the two speeds. Set by the vertical speed alone it drained
a climb to nothing, and any minimum to stop that became a trap the climb
spent its way into - measured freezing at 23 with a gate of 24 and at 3
with a gate of 4. With the product in it there is no gate: as the
sideways speed goes to nothing the trade stops by itself.

Half the product rather than a quarter or the high half. The high half
alone is nought below a product of 256, which is a dead patch exactly
where the turn begins, and a quarter still ran the lander into the roof
before it came round - the whole sky is about 145 pixels.

Measured, placed at 80 sideways and left alone: apoapse at -1024 with 59
sideways, periapse at -124 with 71, and round again at 165, 241, 299 and
369 ticks with no sign of decay. A period of about 22 seconds.

The ceiling also spends one sideways when it wipes a climb. Without that
it was a trap with no way out: the pin wipes the climb, so the trade sees
neither fall nor climb and never touches the speed that is pushing the
lander up. Measured pinned at the top with 117 sideways, unmoving, for
the whole of a four minute flight.

Tests/makedisks.sh needed the library path, since Lander now includes
math.asm, and the app went from 2,924 bytes to 4,651 - mostly the 1,022
byte table - which is what the listing expectations move for.
2026-09-03 19:15:50 -04:00
AnachronautandClaude Opus 5 f5642f52b5 A ceiling that pins rather than ends
Climbing made a sixteen bit height count down past nought and round to
65535, so a lander that kept going came back through the bottom and hit
the ground FROM ABOVE. Two thousand pixels of climb, which a full tank
reaches easily.

Pinned instead, and told so in the window. Leaving upward is RECOVERABLE -
gravity is always there and a lander with fuel can always come back - so
ending the run would punish a state the player can fly out of. What kills
you out here is running dry a long way from the ground, which is a death
somebody flew into rather than one a boundary handed them.

TWO DIFFERENT LINES, and both were got wrong before they were got right.
The warning covers being AT the ceiling or above it: compared against the
ceiling itself, a pinned lander read as back inside the world the next
frame and the warning was written and wiped sixty times a second, so it
never appeared at all. The pin covers being STRICTLY above it: including
the ceiling dragged the height back every frame and the lander could never
descend, which is a lid nobody can leave and worse than the wrap.

And only the climb is spent, never the fall. Zeroing the speed outright
pinned it there for ever - gravity adds once a tick and a clamp running
every frame wiped the pull nine times out of ten.

The orbit check is gone, and the reason is in video.sh. Every window where
the difference showed turned out to be a few frames wide: hold the
thruster and both landers are pinned with their climbs spent, ease off and
both land and freeze. A version of it passed against one disk and failed
against another, which is a check measuring the boot time rather than the
physics. Orbit is verified by measurement and said to be so, rather than
left looking tested.

cosmosLanderDry wants seventy million cycles now instead of forty: it
spends the whole tank at the ceiling before it falls the length of the
world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 18:42:34 -04:00
AnachronautandClaude Opus 5 d896c9d433 Multiplying, on a machine with no multiplier
a * b = qs[a + b] - qs[|a - b|]     where qs[n] is n squared over four

because (a+b)^2/4 minus (a-b)^2/4 is exactly a*b, and the halves the
flooring throws away cancel between the two terms. A multiply is two
lookups and a subtract.

AND THE TABLE IS BUILT BY ADDING, which is the part that makes it fit a
machine with no multiplier at all. A table of squares would need squaring
to fill; this one does not, because qs[n] = qs[n-1] + n/2, and n/2 goes 0,
1, 1, 2, 2, 3 - a number that steps up on every even n. So the whole thing
is a running total and a toggle, and nothing harder than an add appears
anywhere in building the thing that does the multiplying.

511 entries of two bytes, because a byte plus a byte reaches 510. That is
1,022 bytes of Data Memory, and it is the price: a kilobyte traded for an
operation the hardware has not got.

The operands go in memory rather than in registers. B cannot be stored and
a product does not fit in one byte anyway, so two in and two out would
spend more instructions shuffling than the multiply costs.

Checked against nought, the commutation both ways round, a square, and 255
times 255 - which is 0xFE01 and the largest product two bytes hold. The
square is the case the identity leans on hardest: the difference term is
nought and the whole answer comes out of one entry.

Wanted for Lunar Porter's orbit, where the trade between height and speed
has to be proportional to vx times vy and could not be. Useful well beyond
it: this is the routine every fixed point sum on this machine has been
doing without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 18:07:07 -04:00
AnachronautandClaude Opus 5 a069ee7a00 Orbit, and two bars that answer instead of reporting
Going sideways lifts the moon off you. Not because gravity weakened -
because at speed the surface falls away underneath as fast as the lander
falls towards it, which is what an orbit is.

A GRADIENT OUT OF INTEGER ARITHMETIC. Gravity is one sixteenth of a pixel
a tick and there is nothing between that and nothing, so it cannot be
scaled down. Instead four times the sideways speed goes into a byte every
tick and the tick's gravity is skipped whenever that byte carries: the
fraction cancelled is the speed over 64, smoothly, with no multiply and no
divide. At four pixels a frame it carries every time. That is the linear
approximation; the honest one is the square, and wants a table.

It did nothing at all for its first two versions. Once because the relief
was a 256th a tick, so orbit wanted a speed no lander would reach; and
once because A IS THE HIGH HALF of the shift register, so multiplying by
four left the answer in A while the code read B, which is nought. The same
trap as the scroll register and the pixel conversion before it.

And a bar for the vertical speed beside the one for drift, both GREEN
WHILE A LANDING WOULD SURVIVE AND RED WHILE IT WOULD NOT. That turns two
numbers into one question - can I put down - and answers it at a glance.

WHAT THIS COST: the flown delivery check. A recording is a list of buttons
and not a flight, so replaying it under different gravity flies somewhere
else; the delivery became a crash two columns short. The fixture is still
there and is still a faithful record of what somebody did, and is no
longer a record of what happens.

That is the standing cost of a flown fixture, and it is worse than the
transcript tests dropped earlier: those broke when an output moved, and
this breaks whenever a NUMBER moves. Making the delivery reachable without
flying - a way to start already carrying, or at a chosen base - is what
would fix it properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 16:25:56 -04:00
AnachronautandClaude Opus 5 4b1c3d8e3f A missing file is an error, not a core dump
Naming a pad file that is not there printed the error and then said the
machine had STARTED. MACHINE_OK is nought and the code returned nought, so
the front end ran a machine whose clock had never been set up and divided
by it: a typo in a path came out as a floating point exception and a core
dump.

The trap is two functions in one file with opposite conventions -
machineStart returns MACHINE_OK for worked, machineRestart thirty lines up
returns 1 for worked - and this copied the nearer one. Both of the returns
I added last week had it.

Checked now for all three files the replay suite is about, because the
same mistake fits all of them, and re-broken to be sure: the check comes
back exit 136, which is a signal 8, which is the crash.

Found by somebody typing a path that was not there, which is the fourth
thing this week that no test would have reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 15:50:40 -04:00
AnachronautandClaude Opus 5 60e0196fe2 A delivery, flown by hand and kept
Cyan base to red base: twenty five seconds of steering, recorded with
--record-pad and replayed as a test. THE FIRST FIXTURE HERE THAT WAS
PLAYED RATHER THAN WRITTEN.

It is the only check that a cargo ever reaches anywhere. Several attempts
at authoring a flight like it by hand got within two columns and no
closer, which is a piloting exercise rather than a test - and the whole
reason the recorder exists.

What is checked is the FIRST LETTER of what the base answers. Delivered,
Loaded, Nowhere and Not are 30, 22, 37 and 37 pixels of white in that
cell, so a D is a delivery and nothing else is. Counting the whole message
would pass on any message of the same length, and comparing the picture
would fail the next time anything about a font changed.

It took three flights to get here and two of them were lost to bugs in the
recorder: one that recorded the wrong pad, and one that recorded a pad
sampled on a different clock from the one the machine read. Both were
found by somebody watching a replay and saying it was not what they flew,
which nothing in this suite could have said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 14:54:00 -04:00
AnachronautandClaude Opus 5 ee77d79780 A pad is sampled once a frame, and the recorder writes what was sampled
The live state is written by whatever watches real hardware, on ITS clock:
a window polls its keyboard once a HOST frame, which is not a machine
frame. Read straight through, that made a pad whose value could change in
the middle of a machine frame - breaking the one promise the device makes,
that asking twice in a frame gives the same answer both times. The manual
said it could not happen and the code allowed it.

It also made recordings that were not of the flight. The recorder sampled
on a frame boundary and the program read whenever it read, so the two saw
different bytes. A replay of that is a DIFFERENT FLIGHT, faithfully
reproduced: it flew a lander off the top of the screen that had never gone
there, and every check said the replay was deterministic and re-recorded
as itself, because it was. Both were true and neither was the point.

So the live state is latched once a frame. What the machine reads and what
the recorder writes are now the same thing by construction rather than by
two clocks happening to agree. Real hardware latches a controller once a
frame for the same reason.

WHAT IS STILL NOT COVERED: the latch itself. Every recorded pad already
changes only on a frame, so the tests cannot tell a latched live pad from
an unlatched one - the case that went wrong is the one with a real hand on
a real controller, which is the case a headless suite has none of. Said
here rather than left to look tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 14:43:58 -04:00
AnachronautandClaude Opus 5 de1857f5f7 Record every pad, not the one that happened to be first
The first recording ever made with this came back 1,766 frames of nothing.
It recorded pad NOUGHT and the controller was somewhere else - which pad
one lands on is an accident of the host, the same accident that made Lunar
Porter read all four in the first place - and a flight flown for the
purpose was lost to it.

So every pad is or-ed into the byte. A demo is a record of what somebody
DID, and on a machine one person is playing the number it arrived on is
not part of that. It plays back on pad nought, where --pad puts the first
file given, and any program that reads more than one pad reads them or-ed
anyway for exactly the same reason.

--record-pad takes one file now rather than filling pads in turn, because
there is nothing left for the second one to mean.

The check for it plays a recording on pad ONE with nought holding nothing
and requires the bytes back. That is the case that was missing: the round
trip was tested and passed, on pad nought, which is the only pad it could
not have gone wrong on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 14:27:58 -04:00
AnachronautandClaude Opus 5 eb695a3f3b --keyboard was a flag that did nothing behind a window
Voyager installed its own input hook after machineStart, which had already
installed the keyboard file's - so naming a keyboard file and running the
window silently got the window, and the flag said nothing about being
ignored.

Both together is the combination a demo wants. Recording a flight needs
the typing that STARTS it to be the same every time, because a human
reaching the shell a moment later shifts every frame of the recording
after it - while the flying itself has to come from whatever is actually
in somebody's hands.

So the window only takes the keyboard when no file was named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:52:17 -04:00
AnachronautandClaude Opus 5 a163c670d0 A demo recorder: --record-pad writes what --pad reads
One byte a frame, in exactly the format the player takes, so a recording
needs no conversion and there is no second format to keep in step. That
symmetry is the feature, and it makes the strongest form of the claim
testable: a recording is made OF a playback, and the bytes coming out have
to be the bytes that went in.

It exists because some inputs cannot sensibly be written by hand. Flying a
lander from one base to another is a few hundred frames of steering that
has to arrive somewhere eight cells wide, and several attempts at
authoring one got within two columns and no closer. That is a piloting
exercise rather than a test. Playing it once and keeping what happened is
the answer.

A BYTE FOR EVERY FRAME, written inside the loop that advances the
recordings rather than after it, so a machine that jumped several frames
at once still writes one for each. A recording is a timeline: one that
skipped the frames nobody looked at would play back faster than it was
flown.

What is recorded is what the DEVICE WOULD REPORT, not the live state - a
recording of a playback that wrote the live state would be a file of
noughts. And it is flushed as it goes, because a recording is usually
stopped by whoever is playing rather than by the program ending, and a
demo lost to a buffer is a demo flown twice.

Tests/replay.sh is where this and whatever follows it are checked. Twelve
scripts now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:38:43 -04:00
AnachronautandClaude Opus 5 caf5e1f99d A base speaks in the window, not into the world
Two bugs with one cause. The console draws into the map, so a message
printed while flying was a message the lander then flew over - and
printing scrolls, so every one of them moved the whole world up a row. The
window is at a screen position and forty cells wide, and neither is true
of it.

So the window is two rows now: the gauge, and whatever there is to say.
The letters are ordinary tiles, because the character generator starts at
the space and glyph n is character n less thirty two. The rest of the row
is blanked after every message, or a short one would leave the tail of a
long one behind it.

Opening the throttle wipes the line, because a message that outlived the
moment would be read as describing this one.

The crash still goes to the console, deliberately: it is the last thing
the program says and it should survive the program.

A or Start continues from a message as readily as a key does. Somebody
flying on a controller should not have to reach for the keyboard to say
they have read something.

AND TWO TESTS WENT WITH IT, which is the interesting part. cosmosLanderSoft
and cosmosLanderPadOne asserted on lines in a transcript, and the lines
moved off the console - so both went on passing while checking nothing at
all. A test that asserts a side effect rather than the thing itself is
always one refactor from being decorative. What they were for is now
checked in the picture, where the message actually is.

The lander check moved earlier too. The window grew to two rows, so by 1.5
million cycles the lander had climbed behind the status bar - the window
doing exactly what it should, and leaving the check counting six pixels of
a forty pixel lander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:23:12 -04:00
AnachronautandClaude Opus 5 bfb515e23b Cargo: bases with names, and a landing that is not an ending
Four bases, told apart by the scheme their pad is drawn in, so "the cyan
one" is a thing a person can say and a thing the machine already knows.
Yellow is missing on purpose: it is the lander, and a base the same colour
as the thing landing on it would be a poor joke.

Land empty at a base and it loads cargo for the base ACROSS THE MOON, two
along - so the pairs are cyan with red and green with blue, and the
wrapping surface is a route rather than scenery. Land carrying at the
right one and it takes the cargo and pays eighty units of fuel. Land at
the wrong one and nothing happens, which is why the destination will want
to be on the screen.

A LANDING NO LONGER ENDS THE RUN. The lander rests where it is, exactly on
the surface with both speeds zeroed, until the throttle opens again -
which is the only way to stop being landed. Gravity does not pull on
something already sitting down, and a base does not hand out cargo sixty
times a second to a lander parked on it.

The pad array holds the base's number plus one rather than a flag. Nought
still means no pad, so it is still one lookup, and a flag would have to be
followed by "and which of the four" - the same walk done twice for an
answer already in hand.

Pads are eight columns rather than four. Four was 32 pixels in a moon 1024
round, which is a target somebody flying by feel misses over and over.

WHAT IS NOT COVERED, and why: the delivery and wrong-base paths need a
lander flown from one base to another, and hand-authoring a recorded pad
input that hits an eight column pad across a 128 column moon is a piloting
exercise rather than a correctness one. Several attempts got within two
columns. Loading, crashing, landing off a pad and running dry are all
covered; delivery is built and flown by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:04:27 -04:00
AnachronautandClaude Opus 5 7257ad369c Landing pads, carved rather than looked for
A random walk does not leave flat ground and a lander wants some. Four
pads are cut into the moon after it is generated, each four columns
levelled to whatever height its first column happened to have - so they
sit in the landscape rather than on a shelf above it. The moon decides
where they are; this only decides that they are flat.

Searching for flat spots was the alternative and it can fail, which means
a fallback that carves anyway - the carving, plus a search nobody needed.

They are marked by an ATTRIBUTE and not a tile of their own, which costs
no art at all: a nibble is added to every index in a tile, so one solid
block is grey moon or a cyan pad depending on the byte beside it.

Which columns are pads is an array, because asking has to be one lookup.
Four comparisons per column per row is 12,800 of them for one screen, and
the landing verdict asks the same question again.

THE LANDER STARTS ABOVE ONE, because that is where a porter's day begins.
Starting in the middle of nowhere meant a straight descent landed in the
middle of nowhere, which is a fine thing to be able to do and a poor thing
to have to.

That change cost the crash test its teeth, and the way it did is worth
keeping. It held nothing at all and let the lander fall - and a short drop
onto the high ground of the base you started above is survivable, which is
correct, and left the test saying nothing. It holds Right now: lateral
speed has no limit and nothing slows it, so a slide always ends badly
however the rest is tuned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 11:45:30 -04:00
AnachronautandClaude Opus 5 6073086584 The system takes a leftover window down
Lunar Porter put a fuel gauge up and never took it away, so the shell came
back with FUEL across the top and the cursor underneath. Clearing did not
help: a window is a layer at a SCREEN position that does not scroll, which
is exactly what makes one left behind unpleasant - it sits over whatever
comes next and cannot be scrolled off, cleared away or typed past.

Taken away rather than given back, like the sprite table and for the same
reason: nothing the shell draws is a window, so there is nothing to
restore. And a program that FAULTED while one was up could not have taken
it down itself, which is why this belongs to the system rather than to
whichever programs remember.

The check for it needed writing twice, and the first version was the
familiar kind of wrong. It counted the gauge BAR's colour - and the bar
disappears on its own whatever happens, because it is drawn with a tile
the screen save puts back, so the check passed with the teardown deleted.
What actually survives is the LABEL, in font tiles the shell needs anyway.

So the screen is cleared afterwards and read cell by cell. With the window
down a cleared row is "> " and a cursor; with it up the same row is F, U,
E, L. Cells one and three being empty is the whole difference, and it is
29 and 22 pixels of it rather than a threshold somebody has to believe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:50:42 -04:00
AnachronautandClaude Opus 5 2274be4b68 Lunar Porter, rung three and a half: fuel
Every thruster costs a unit every tick it fires, so holding two at once
costs two - the honest price, and it makes a drift you corrected expensive
in a way a drift you avoided is not.

AN EMPTY TANK IS NOT AN ENDING. There is no message and nothing stops: a
lander with no fuel is still flying, it just cannot do anything about
where. What happens next is gravity, and gravity is patient. The test for
it holds the thruster from the first frame to the last and crashes anyway,
which is what says the fuel is real - a lander that could hold Up for ever
would land every time, and the economy this is the first half of would
have nothing to buy.

The gauge is in the window, which is what the window was built for two
commits ago: a bar at a SCREEN position, so the moon turning underneath
does not carry it off. Thirty five cells after a label, redrawn whole
every frame because seventy bytes out of one port is cheaper than working
out which of them changed.

A byte of fuel, and a byte is enough. Over eight it is a bar of up to
thirty one cells - a shift, because there is no divide - and at a unit a
thruster a tick it is about forty seconds of holding the engine open.
Sixteen bits would be more arithmetic for a number nobody reads to the
unit.

Cargo and the bases are the other half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:35:34 -04:00
AnachronautandClaude Opus 5 17c8da111f A window: a layer that does not scroll
The map moves and this does not, which the map alone cannot express. The
scroll registers move ALL of it, so a score printed into the map slides
away, and one printed into whichever rows the view happens to be showing
jumps a pixel at a time as the fine offset changes.

Port 0x3D is how many rows tall and 0x3E is which row it starts at. Nought
tall is no window, so a cleared screen has none and every program written
before this means what it meant. A start row is a register because a
status bar along the bottom is as common as one along the top.

IT HAS ITS OWN MEMORY, and that is the argument for it. The cheaper design
draws the top rows of the MAP without the scroll applied - no new memory,
one register - and makes those rows part of the playfield's ring, so a
game that scrolls vertically has to route its world around its own
scoreboard for ever. The point of a status bar is that it is not somewhere
in the level. Lunar Porter does not scroll vertically today and will the
moment an orbit is a thing you can reach.

0xC000 in the screen bank, which the map does not reach: it ends at
0xBFFF. Same cells, same tiles, same pages, same schemes. Being in the
screen bank makes it per screen, so flipping the buffer flips the status
bar with it - what a double buffered game wants, and surprising the other
way round.

Drawn over everything, sprites included. A sprite that could cover the
fuel gauge would be a bug in every game that had both.

Tile modes only. In bitmap mode the picture is using that memory, so a
bitmap program pins things to the screen with sprites, which are in screen
coordinates for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:19:09 -04:00
AnachronautandClaude Opus 5 e3eccd17a8 Settle the view before saying how the landing went
The console draws into the map and the map is what is being scrolled, so a
message printed while the view was three pixels into a cell came out three
pixels off the top, with as much of its first row missing as the cell above
it had lost.

The flying is over by then, so the fractional part of the view has no more
work to do. Putting it back is what makes the whole message visible.

This is not the general problem. A status bar that has to stay readable
WHILE the map moves is a different thing entirely, and nothing here solves
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:02:59 -04:00
AnachronautandClaude Opus 5 a074a831f0 The shell scrolls, and the moon was drawn where nobody was looking
Lunar Porter never put the row origin back. The map is a ring 128 rows
tall that the screen shows 25 of, and the shell leaves that origin
wherever its last command finished - so a moon drawn into rows nought to
24 while the screen is reading from row forty is a moon nobody can see. It
came out as terrain missing, or half there, depending on how far down the
prompt had got. Running Pad first was enough; so was holding Return.

Nothing here is tidiness. It is the difference between the rows a program
WRITES and the rows the screen READS, and only one of those is under the
program's control. Grid has always known this; Lander did not.

The check for it needed writing twice. Forty returns caught nothing,
because the shell runs an eighty column screen which is FIFTY rows tall -
forty returns fill it and never scroll it, so the origin was still nought
and the test passed against a build with the fix taken out. The screenful
that matters is the one the shell is using, not the one the program is
about to ask for. At eighty it is 28,608 pixels of moon with the fix and
none at all without it.

break.sh is what said so. The first version of this check looked exactly
like a passing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 23:51:48 -04:00
AnachronautandClaude Opus 5 f7be843ed9 Lunar Porter takes any controller, not the first one
A controller does not always arrive on pad nought. The front end hands out
the numbers the host gave it, so a game that reads only the first one
works on the machine it was written on and silently does nothing on the
next - which is the shape of "the pad is detected, Pad shows it, and the
game ignores it".

Four reads and three ORs. One person flies this and which socket they
plugged into is not a thing they should have to know. Presence is any of
the four bits rather than the low one, for the same reason.

The manifest's pad column takes several fixtures now, comma separated, and
they fill the pads in turn. So cosmosLanderPadOne holds nothing on pad
nought and flies the whole landing on pad one - a test that fails on the
version of this program that shipped an hour ago.

Also confirmed while looking: raylib 6 does refresh which gamepads are
ready every frame in PollInputEvents, so a hot-plugged pad should be seen.
Whatever is stopping that is above us and worth a separate look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 23:36:05 -04:00
AnachronautandClaude Opus 5 6def343e97 Find Raylib's link flags instead of guessing one set
Raylib 6 calls X11 directly from GetClipboardImage, so a static libraylib
needs -lX11 on a Linux desktop where 5.6 did not - and because it is an
archive, one function nobody calls drags in the whole object and every X
symbol with it.

THE OLD SHAPE FAILED IN THE WORST AVAILABLE WAY. One hardcoded guess was
test-linked, and when the guess went short the probe reported that Raylib
was not installed - so make quietly stopped building Voyager and said so
in the words it keeps for a machine with no graphics library at all. The
answer looked like an absence and was a missing flag.

So the candidates are tried in order and the first that links is the
answer. The probe and the flags are the same thing now rather than two
facts that can disagree, which is the property that was actually missing:
a probe that tests something other than what gets built can only ever be
a coincidence.

Verified against 6.1-dev: Voyager builds and all 210 tests pass under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 23:24:06 -04:00
AnachronautandClaude Opus 5 df50c2f0f8 The pad was working; the game was told there was not one
0x64 counted only the RECORDED pads. So a controller plugged into Voyager
reported its buttons perfectly, and every game asking whether there was a
controller was told no - which is exactly what Lunar Porter asked, once,
at startup, before falling back to the console for the rest of the run.

The cause is worth naming: a front end calls padSet every frame for every
pad, so "held nothing" is the commonest thing it says and cannot also mean
"there is no pad here". Connected is said separately now. Pad nought is
always there behind a window, because the keyboard is behind it - which is
the useful answer rather than the literal one.

And Pad.asm, which is what should have existed before any of that guessing
began. It prints a line whenever a pad changes, and tells apart the three
states that look identical from inside a game that will not respond: one
nobody noticed, one mapped to nothing, and a mapping that is wrong.

WHY A PROGRAM AND NOT A PRINT IN THE FRONT END: because the question is
what the MACHINE can see. A front end reporting what it thinks it is
sending answers a different question, and the gap between those two is the
whole of this bug.

It also found that osPrintNumber takes A as the HIGH half - the same way
round as the shift register and every other pair here, and not what a byte
in A wants. Every value came out 256 times too big.

Gravity is one frame in ten rather than six. The ratio between thrust and
gravity is the feel; how often the tick comes round is how fast that feel
arrives, and one in six was still touchy. Same lander, more time to think.

And the verdict waits for a key. It printed and left immediately, taking
the screen with it - so the one thing worth seeing, the lander sitting on
the ground it had just reached, was gone before it could be looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 22:48:24 -04:00
AnachronautandClaude Opus 5 8eb4e4d67e Lunar Porter, rung two: it lands, or it does not
The terrain is an array in Data Memory rather than something read back out
of the map, and that is the whole reason this is cheap: the ground under
the lander is one index into 128 bytes, where asking the screen would be a
transfer through the controller every frame.

The column is the world position over eight, masked to the moon's 128. The
surface is that column's row times eight - three turns left of the shift
register, since a row is at most 24 and 192 fits in the low half. The feet
are the lander's top plus its eight pixels.

WHAT DECIDES IS THE SPEED AT THE MOMENT IT ARRIVES. Both of them, and both
have to be gentle: three quarters of a pixel a frame downwards and half of
one sideways. Sideways is the tighter on purpose, because a landing that
was soft downwards and sliding is a lander on its side - which is the
interesting half of the difficulty, and the half the drift bar was blind
about until it existed.

Two fixtures say it works, and they differ only in what was held: one
holds nothing and falls the whole way, the other pulses the thruster six
frames in sixteen and survives. Same terrain, same seed, same keys.

Also: the gamepad did nothing, and the reason is that the four direction
buttons are the D-PAD. A lot of controllers made this century have one
nobody uses - the thumb goes on the stick, which reports as an axis rather
than a button - so a pad that was plugged in and working correctly did
nothing at all. The stick counts as held past halfway now. Untested here,
because there is no controller in this environment and the suite runs
headless; Voyager also says at startup which controllers it can see, so a
pad that still does nothing can be told apart from one nothing noticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 22:33:32 -04:00
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
AnachronautandClaude Opus 5 553882d28d Start CosmOS with a script, and let a script hold its tongue
Three things scripts wanted, and they are one thing: a machine that can
have a face.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things this cost that were not obvious:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 15:38:02 -04:00
AnachronautandClaude Opus 5 e52d8d84f8 Collapse the two makefiles into one
Programs/makefile is gone and everything it did is here. The split was not
paying for itself: nothing at this level ever ran that file, so it rotted,
and all three bugs found in it this week share that one cause. It named two
source files that had been renamed months earlier and failed outright. The
disk did not depend on the tree it mirrors, so a new file silently was not
on it. And the disk was in no default target, so 'make clean' threw it away
and 'make' did not bring it back - which is what prompted this.

The platform and the system stay separate, as TARGETS rather than as files:
'make SplitBit Assembler' builds the machine and its tools, 'make cosmos'
and 'make disk' build the system, and somebody who wants to write their own
system can ignore the second group entirely. That boundary is now one make
enforces, which the directory boundary never did - Tests/makedisks.sh has
always reached across it to build its own fixtures.

'make' now builds a bootable disk as well as the tools, because a machine
with nothing in the drive does not do anything and the first thing anybody
wants after building this is to watch CosmOS come up. It costs half a
second: the whole system, twenty six apps and the native assembler assemble
in less time than the emulator links.

Two things the merge needed that the split did not. Assembling now takes an
order-only dependency on the assembler, which came free when you had already
built the tools before changing directory. And the mirror is told which
directory to walk: it was ".", meaning Programs/, and left alone it would
have meant the whole repository - the C sources, the tests and the manuals
mirrored onto a disk for an 8-bit machine.

The disk comes out at 188 files where it was 189. The one that went is
Programs/makefile, which was itself being mirrored onto it as
/Source/makefile.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 22:33:33 -04:00
AnachronautandClaude Opus 5 85329f13c3 Take a device's line down when its status port is read
A device raises a line and something has to take it down. Two things did:
being interrupted, and being woken from WAIT with the Interrupt Flag down -
the second because a masked program has nowhere to dispatch to, so nobody
else would.

There was a third way to learn a device had finished and nothing answered
it. The documented idiom reads the status, branches out if the device is
already done, and only WAITs otherwise; on a disk quick enough to finish
before the first look, which is every disk here, the WAIT is unreachable.
The line then stood for the rest of the machine's life.

The program that leaves it standing never pays for it - it was masked
throughout. The bill arrives at whoever next sets the Interrupt Flag. The
boot chain reads the disk to load a program, leaves the line up, and hands
over; the loaded program is then interrupted on behalf of a read that
finished before it existed, through a vector table with no entry for a
device it never touched, and faults on the instruction after its SIF.

Found by running Examples/tune.asm through Once. It set up its whole sound
and died four bytes before its first note, which is why it was silent
rather than wrong - and why it looked like a sound bug for a while.

So reading the port that answers a device takes its line down, the same way
taking the byte already took the console's down. Disk and screen do it on
their status port. And a reset now clears every line, which is the sentence
the manual already makes about the vector table: a handler left behind aims
an interrupt into a program that is no longer running, and so does a line.

testPrograms/diskLineTest.asm pins it - the racy idiom, then SIF with no
handler installed anywhere. It faults without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 22:15:02 -04:00
AnachronautandClaude Opus 5 62a657f1a7 Give Voyager a speaker
The device already made the samples; this takes them out of its ring and
hands them to Raylib, a sub-buffer at a time. Nothing here decides what a
sound is, the same as nothing in presentFrame decides what the screen
looks like - which is why the headless binary and this one make the same
sound, and why the suite can check a device with no speaker in it.

Asked for rather than assumed: a host with no audio device still gets a
window, because a machine worth looking at should not need one to start.

When the ring runs short the missing samples are filled by holding the
last one rather than by zeroes. It is still a glitch; a jump to silence
and back is a click, and a held level is not.

Also removes a comment that had been left in twice above the frame loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 21:05:38 -04:00
AnachronautandClaude Opus 5 8366bf7721 Fix the Programs build, and make the disk depend on what it mirrors
Two faults, both introduced by 0852666 and both invisible because nothing
in the test suite runs this makefile - Tests/makedisks.sh builds its own
images.

That commit renamed 16bitSegmentedSieve.asm to 16bitSieve.asm and left
PROGRAMS naming the old file, so 'make' in Programs/ has failed outright
ever since. Which means the disk under Programs/build has been whatever it
was on that day, and 'make run-voyager' has been booting it.

The second is the same disease one layer up. The mirror exists so that
adding a file is the whole of putting it on the disk, and that only works
if adding a file also rebuilds the disk - but the prerequisites were as
hand-maintained as the list the mirror replaced. tune.asm went into
Examples, the image was not remade, and it simply was not there to
assemble on the machine. Nothing said so, which is exactly the failure a
mirror is for.

Found by looking for tune.asm on the disk and not finding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 21:05:38 -04:00
AnachronautandClaude Opus 5 d388cd3122 Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:17 -04:00
AnachronautandClaude Opus 5 b0d06aa6e5 Make the vendored synth plain ASCII
soundThing's comments use em dashes and an arrow, and this repository is
plain ASCII throughout because the tooling around it does not do Unicode.
Tests/docs.sh caught it the first time it ran against the new file, which
is what that check is for.

Comments only; nothing the compiler sees has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:01 -04:00
AnachronautandClaude Opus 5 f58b0f93af Vendor the voice engine, with the level it always had and could not say
soundThing's synth.c and synth.h, and nothing else of it: the voice engine pulls in maths,
stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the interface, which
is what made it liftable. Copied rather than submoduled - two files against tying this build
to another repository's history is not a close call - so the header carries the whole of the
difference and is meant to stay current.

A VOICE'S LEVEL IS A ROUTING NOW. Envelope 0 was multiplied into the output unconditionally,
so routing it to a filter or an oscillator meant it shaped the volume as well whether that was
wanted or not, which is most of the trouble with making percussion. There was no flag to turn
that off and there never had been: Envelope is the only one of the four modulating things
without an active flag, where LFO, Filter and Oscillator all have one.

It could not take the filter's shape either. filterTick returning its input unchanged is the
right nothing-happens answer, but an envelope returning a constant would be useless as a mod
source - and being a mod source while NOT being the volume is exactly the thing wanted. So the
voice names the source that shapes its level, the way every other destination names its
source, and MOD_SOURCE_NONE means nothing shapes it. Two things fall out that were not
possible before: envelope 1 shaping the volume, and an LFO doing it, which is tremolo.

Envelope 0 still decides how long a voice lasts, because it still ticks. A voice with its
level unrouted will therefore cut rather than fade when the envelope reaches idle - fine or
wanted for percussion, a click if the oscillators are loud at that moment, and a one line
change to end on note-off instead if it turns out to matter.

NOISE COMES FROM A SEEDED GENERATOR, one to each thing that makes it. rand() is global state
shared with the whole process and varies between C libraries, so the same program sounded
different on different machines and no recorded result could have meant anything. One
generator each rather than one shared, because two noise sources drawing the same stream are
not two noise sources. Checked rather than assumed: two synths from the same start produce
identical samples, and 4,617 of 4,800 of them are non-zero, so it is noise and not silence.

AND A CHANNEL IS THE CHANNEL YOU ASKED FOR. synthNoteOn hunts for a free voice and steals
round-robin, which is what a keyboard wants and what the standalone synthesizer still does.
Channel two is channel two. Both old calls are untouched.

Nothing links it yet. It compiles clean and standalone under -std=c11 -pedantic, which is
what make strict already checks, and the 169 tests are unmoved because nothing calls it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:26:35 -04:00
AnachronautandClaude Opus 5 33afc20abc Give the memory controller to a bus rather than to the machine
The third and largest piece of the peripheral core, and like the two before it nothing
changes: 169 tests, and the two that would notice a misroute pass loudest of all.

A CONTROLLER IS THE MOST STATEFUL THING ON THIS MACHINE - a source bank and address, a
destination, a length, five guard registers and a table describing every bank it can reach.
Two processors sharing one would interleave into nonsense: one sets a source, the other sets
a destination, the first issues a blit and moves the wrong bytes somewhere else again. No
arbitration fixes that, because there is nothing to arbitrate - both writes were legal and
the result belongs to neither of them.

So it is a struct threaded through all seventeen functions that touch it, rather than a
pointer to a current one set on the way in. The smaller change was tempting and is the shape
that produced the stale reset flag and the editor's IsNew surviving a second run, both this
week: state that has to be set on the right path before anything reads it. A controller is
where that goes wrong quietly rather than loudly.

THE STATICS WERE DELETED RATHER THAN LEFT, which is what made this safe to do mechanically.
A missed reference is a compile error rather than a variable that still exists and serves the
wrong core, so "did I catch all 165?" stopped being a judgement and became a question the
compiler answered.

Two things the transformation nearly got wrong, both caught by reading rather than by
building. guardStart and guardEnd are fields of Bank as well as registers of the controller,
so banks[n].guardStart had to keep its name while a bare guardStart changed - the difference
between a fence and the register about to be written into one. And a definition and a call
look alike enough that the first attempt turned publishBank(number) into
publishBank(Controller *c, number); definitions start at column zero here and calls never do.

Tests/agree.sh is what says this is right, more than the count does. It builds the same disk
with SplitDisk and with CosmOS and compares byte for byte, and every filesystem operation on
the machine goes through the controller - so a blit that went to the wrong place would corrupt
a disk that is checked against one built by different code entirely. Tests/cycles.sh covers
the other half, since what a transfer costs depends on pendingCycles landing in the right one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 17:32:10 -04:00
AnachronautandClaude Opus 5 5be5bea994 Make the interrupt lines belong to a bus rather than to the machine
The second piece of the peripheral core, and like the first it changes nothing: one array of
bits became a struct, and the machine's own devices reach it through wrappers because every
one of them really is on this bus.

IT WAS ONE ARRAY INDEXED BY PORT FOR THE WHOLE MACHINE. With two processors that is not a
tidiness problem, it is each of them seeing the other's lines: a disk finishing would
interrupt a sound core, a sound core's tick would interrupt the shell, and both would arrive
at a handler installed for something else entirely. Hardware vectors are per port, so the
numbers would even look plausible on the way in.

Unlike the shift register, which was harmless until there was a second core to share it with,
this one was always going to be wrong the moment there was one.

Still to come on this rung: a controller each, since it holds the source, the destination, the
length and five guard registers, and two cores setting those between each other's instructions
would interleave into nonsense. Then the interleaving rule, which belongs in the manual as
machine behaviour rather than as something the emulator happens to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:43:54 -04:00
AnachronautandClaude Opus 5 4e3258e3f7 Put the libraries on the disk, and say which include is missing
Sieve-16.asm, Life.asm and Fib-16.asm could be read on the machine and not assembled on it.
All three #Include print.asm, and print.asm was not on the disk.

/LIB WAS STILL A HAND-WRITTEN LIST. Programs/Libraries is what an #Include means when it is
not a CosmOS source - print, the integer helpers, the maths - and nobody had thought to name
them, which is the exact failure mirroring the sources was meant to end. It is mirrored now,
so the next one nobody thinks of is there anyway.

AND THE ASSEMBLER SAID NOTHING USEFUL. A missing include returned a failure and printed no
message, so the catch-all at the top said "nothing was written", which is true and useless -
finding out why meant reading the assembler rather than the message. It names the file now,
and where it looked:

  cannot find print.asm, not beside the file that asked and not as /Lib/print.asm

Both halves are worth having, because a name that is in neither place is usually one or the
other spelt wrong. The same shape as More's "error 2": a program that knows exactly what
happened, reporting a number or nothing at all.

The disk also depends on the recipe that lays it out now. It did not, so adding the libraries
left an image built without them and the next run reported the same thing missing - which
sends you looking at the change you just made rather than at the stale thing in front of you.
It cost twenty minutes to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:27:24 -04:00
AnachronautandClaude Opus 5 dae3455da0 Give the CPU a bus, so that there can be more than one
The first piece of the peripheral core, and it changes no behaviour: the machine still has
exactly one processor, and every one of the 169 tests still passes. What changes is that the
code has stopped assuming so.

FIVE THINGS A CPU ASKS OF THE WORLD OUTSIDE ITSELF, and every one of them was a call to a
function there was exactly one of: the port handlers, what the controller spent moving memory,
what was spent stopped waiting on a device, and the two that work the interrupt lines. Fine
for a machine with one processor and wrong for a machine with two, because A PERIPHERAL CORE'S
BUS IS ITS OWN - it sees the devices its own device gives it, raises its own lines, and stalls
on its own controller, none of which are the host's.

They are gathered into a Bus that the CPU holds a pointer to, rather than threaded through as
a bus number, because a bus is something a device PROVIDES. A device that provides one should
hand over the answers rather than be looked up by an index somebody else has to keep right.
initializeCPU puts the machine's own there, which is what every processor was on when there
could only be one, so nothing at any call site moved.

And shiftRegister is a local now. It always was one in effect - written and read inside a
single instruction and never carried to the next - but it sat at file scope, where a second
processor would have shared it. Two cores taking each other's shift halfway through an SHL is
a poor thing to discover later, and it cost two braces to make impossible.

Still to come on this rung: the interrupt bitmap, which is one file-scope array indexed by
port for the whole machine; a controller each; and the interleaving rule, which has to be
written into the manual as machine behaviour rather than left as something the emulator does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:11:54 -04:00
AnachronautandClaude Opus 5 f2e26c1852 Stop a pending reset outliving the reset it belonged to
Reset out of picture.bin and CosmOS booted and then halted at once, having been told there
was nobody at the keyboard.

The button set two things: the machine's reset request, and a flag of the window's own that
said "end the next console read, so a machine blocked on a key can get to the point where it
notices". The second was only cleared when the console actually asked - and picture.bin never
asks. It draws and halts. So the flag survived the restart and answered the NEXT machine's
first read with the end of input, which for CosmOS means stop.

There is one fact and it now lives in one place. The window asks whether a reset is still
waiting rather than remembering that it asked for one, so the read ends only while a restart
is genuinely on its way and goes back to normal the moment it has happened. The local flag is
gone.

Two pieces of state meaning one thing, one of them cleared on a path the other did not need -
which is the same shape as the console's line editing flag surviving a second run, a fortnight
ago. Worth noticing twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 14:59:15 -04:00
AnachronautandClaude Opus 5 adefce975b Let the reset button reach a machine that has stopped
The gesture rebooted CosmOS and could not reboot picture.bin, which is the case it was added
for. picture.asm ends in HALT, and a halted machine runs no instructions - so nothing ever
reached the code that notices a reset, because a reset is noticed BETWEEN INSTRUCTIONS and
there are none. It only ever worked because CosmOS was still going.

Which is backwards: a machine that is not going anywhere is exactly the one worth restarting,
and it is the one that cannot hear a request by itself. The restart is lifted out of the run
loop into machineTakeReset, and the window asks every frame whether the machine is running or
not.

NAMED AS EMULATOR MAGIC, because it is. There is no reset line on this machine and no keyboard
controller to assert one; the window reaches in and sets the same flag the machine port sets.
When those are designed, a keyboard controller will have to see the gesture and pull reset
regardless of what the CPU is doing - which is the property that matters and the one a port
write can never have, since a port write needs a program willing and able to make it. The
shape of that is already visible here: asking every frame rather than leaving it to the
machine to notice is what a line does.

A restart now clears the cycle limit as well, since a machine stopped for reaching one is
another thing somebody would press the button over.

The three existing reset tests still pass, and they are the ones that matter: they exercise
the same restart through the machine port. What no test reaches is the gesture itself, which
exists only when there is a window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 14:48:31 -04:00
AnachronautandClaude Opus 5 8c6ed62044 Reset on a gesture the host has no opinion about
Control, Shift and R. It was Control, Alt and Delete, which brings up a shutdown prompt
rather than reaching the machine - and no amount of asking will change that.

IT IS A SECURE ATTENTION KEY. Every serious operating system reserves it so that it always
reaches the system and never an application, precisely so a program cannot imitate a login
screen. On Windows an application cannot see it without a kernel driver; on Linux the desktop
takes it. That is not an oversight to work around, it is the same guarantee a reset button
wants being enforced one layer further down, and there is no call this program could make
that would win the argument. It is unavailable for exactly the reason it seemed right.

So the requirement list grew a third item that was not obvious until it bit: the gesture has
to be one the host has no opinion about. Control and Shift with a letter is about as free as
a combination gets - not window management, not a virtual terminal switch, and not a shortcut
any desktop claims by default. It keeps the other two properties: plain F12 and plain Delete
stay free for software, and three keys are not pressed by accident.

If some platform does send a character for it, nothing comes of that either. Whatever arrives
is in memory that is about to be thrown away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 14:41:05 -04:00
AnachronautandClaude Opus 5 0afead00de Make the reset a gesture rather than a key
Control, Alt and Delete, which was F12. Two reasons, and the first one is about hardware
rather than about the emulator.

ON REAL HARDWARE THIS IS NOT A KEY AT ALL. A Voyager has a button on the case, and what a
window has instead of a case is a gesture standing in for one. So it must not be a key
software might want - a machine with a keyboard has function keys on it and something will
eventually have a use for F12, which would have meant taking it away again later, from
programs already written to expect it.

And it must not be reachable by accident. Restarting throws away everything in memory, which
is the same data loss Escape was one keypress from causing, arriving by a different door.
Three keys together are not pressed by mistake.

It has meant this since 1981, so it is also the one gesture nobody has to be told the meaning
of - and Delete stays free for software, since the gesture is all three or nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 11:09:41 -04:00
AnachronautandClaude Opus 5 f7657081be Put a reset button on the case, and stop Escape closing the window
ESCAPE WAS A BUG I LEFT. This machine sends Escape to the console like any other key, and
Raylib closes a window on Escape unless it is told not to - so a program reading keys could
be ended by one of them, taking whatever was in memory with it. SetExitKey(KEY_NULL), and it
is a byte again.

F12 is the reset button. A button on the case rather than a key the machine can see: nothing
sends a function key to the console, so nothing can be surprised by one. It does what writing
MACHINE_RESET does, which is that the machine starts the way it started - the boot chain runs
again and finds whatever the disk now says to run.

Which is what makes a bare metal program escapable. Once puts a demo in front of the next
start and deletes the request before jumping, so a demo that has taken the whole machine is
one keypress from the system coming back, instead of closing the window and opening it again.

IT HAD TO REACH A MACHINE THAT IS WAITING, and that took two more things. A reset is acted on
between instructions, and a machine blocked on a key is part way through one - so the button
would have set a flag that nothing ever came along to notice, in exactly the situation a
reset button is for. The wait ends now: the console is told its input is over, which it is
for a machine about to stop existing.

And the reset puts the console's input back - nothing pushed back, no line half gathered, and
not at the end of input. That was already wrong before the button existed: a reset after the
input ran out left a console that had run out afterwards, so a machine could be restarted
once and then never typed at again.

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

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

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

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

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

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

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

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

Status bit 0 goes up when a frame has gone by and reading the status port puts it down, so a
program with no handler can watch for it instead. Control bit 0 asks to be interrupted, and
is OFF when the machine starts: an interrupt with nothing installed to catch it is a fault,
so a screen that began interrupting the moment it was switched on would take down every
program written before frames existed.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 10:24:09 -04:00
AnachronautandClaude Opus 5 3da020898c Write down what the machine costs, before it has to be worked out again
Three measurements that would otherwise be re-derived from scratch in three months, put
where a reader will meet them rather than where somebody has to know to look.

The README's cost model section says what a nominal 1 MHz actually means: about 270,000
instructions a second, since the average instruction costs 3.72 cycles. The same program
therefore takes 3.72 times the wall clock it did when a cycle was an instruction - nothing
got slower, the number got honest, and the number it replaced described no machine anybody
could build. Beside it, the two figures that put that in proportion: the emulator runs 195
million cycles a second in --fast, and CosmOS assembling CosmOS is 654 million of them,
which is eleven minutes at a megahertz, 3.3 seconds at --fast, and six and a half seconds at
a hypothetical 100 MHz. That last is pessimistic rather than hopeful: it assumes hardware
overlaps nothing, which is the same conservative reading the controller's cost model takes.

The CosmOS README gets what that means for its assembler, which is superlinear in what it
reads: 1,383 cycles a byte for a four kilobyte source, 6,290 for a hundred kilobyte one.
IT IS NOT THE DISK, and the number that says so is worth keeping - 654 million on a disk
carrying the whole source tree against 653 million on a flat one with a sixth as many files.
The suspected cause is walking a table of some nine hundred labels once per reference,
suspected rather than measured.

Nothing is being done about it, and the reason is written down too: development happens with
the host assembler. But faster hardware buys a constant factor and does not change the shape
of the curve, so the program that forces this is not CosmOS - it is the first one twice its
size.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 09:54:29 -04:00
AnachronautandClaude Opus 5 7073b972e6 Say what went wrong, and give the file tools room for a path
The makefile on the disk was fine. "Makefile" is not "makefile", and SBFS names are case
sensitive - but neither tool said so, and both failed in ways that pointed somewhere else.

MORE PRINTED A NUMBER THAT MEANT NOTHING. "cannot find the file, error 2" invents a
vocabulary the system does not have: the filesystem library documents its answer as zero or
not zero, never as a code, so 2 could not be looked up anywhere. It says "there is no file by
that name" now, which is the only way opening fails that a person can do anything about, and
is nearly always a name typed slightly wrong.

EDIT SAID "0 LINES", which is also what an empty file that IS on the disk says. A name typed
slightly wrong therefore looked exactly like the document you meant to open, right up until
you saved it somewhere new. It says "new file" instead.

Two bugs came out of writing that, and both are worth more than the feature.

The first is mine and the label lied to me: loadNothing is not where a load FAILS, it is
where every load FINISHES, reached at the end of splitLast on files that opened perfectly
well. A flag set there on the strength of the name was set on everything. It is called
loadDone now, and the failure has its own name.

The second is older and general: a program is loaded once and may be run many times, so
"load Edit.sbx" then "run" twice is two sessions over one copy of the Data Segment. Anything
a session changes has to be put back by the session. A zero written in the Data Segment is
the state a program starts in the first time and never again - and cosmosEdit runs Edit twice
from one load, which is why it caught it immediately.

AND THE FILE TOOLS COULD NOT ADDRESS THE TREE THEY NOW HAVE. Edit took 23 characters of name
and More and Type took 29, which were right when everything lived in the root. With the
sources mirrored onto the disk, "/Source/CosmOS/Assembler/classify.asm" is an ordinary thing
to type - thirty-seven characters, cut down to a name meaning something else, or nothing. All
of them take sixty-three now, which is what the shell reads of a command line, so nothing
longer can arrive. Wander with it, since a directory is a path too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 09:24:10 -04:00
AnachronautandClaude Opus 5 0852666e73 Mirror the source tree onto the system disk
A list of files in a makefile goes stale the moment somebody adds a program and forgets to
name it, and what they forgot is invisible until they go looking for it on the machine. So
SplitDisk gained a mirror command and the disk rule is one line: putting a file where the
others live is now the whole of putting it on the disk.

EVERY FILE GOES THROUGH put AND EVERY DIRECTORY THROUGH mkdir. That is the point of it -
mirror adds a walk and no filesystem code at all, so anything the format refuses here it
refuses everywhere, in the same words. What is new is the walk, and the walk is what the
six checks in Tests/disk.sh are about: that it goes all the way down, that it leaves dotfiles
and named directories behind, and that a name too long stops it.

REFUSED RATHER THAN SKIPPED, because a disk quietly missing a file is the exact failure a
mirror exists to prevent. Which meant four sources had to be renamed - a directory entry
holds 22 characters and they were 23, 23, 24 and 29:

  16bitSegmentedSieve.asm        -> 16bitSieve.asm
  16bitSegmentedSieveModern.asm  -> 16bitSieveModern.asm
  consoleInterruptTest.asm       -> consoleInterrupt.asm
  controllerWriteTest.asm        -> controllerWrite.asm

The test names in the manifest are unchanged, since those are identifiers and every recorded
result is filed under them. Only where the source lives has moved.

The entries are sorted before anything is written. readdir hands them back in whatever order
the host filesystem feels like, and a disk image that comes out different from one run to the
next is an image no test could compare against another.

The disk grew from one megabyte to four and from 192 directory entries to 1,024. The sources
are 2,850 blocks and the mirror filled the old directory on its first run, which is a thing
that should not need thinking about again.

The Tests fixture disk is deliberately NOT mirrored. It is a controlled fixture with known
contents, and the shipped disk is the one meant to be useful; they want different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 08:45:46 -04:00
AnachronautandClaude Opus 5 ff4b025058 Make the cursor blink while the machine is waiting, and show how the palette works
THE CURSOR DID NOT BLINK, and the reason is worth stating: it blinks on the machine's clock,
and the machine's clock had stopped. A console waiting on a key stops the CPU, so no cycles
passed, so the phase never moved - and the one moment somebody is looking at a cursor is the
moment they are being asked to type.

Waiting is now charged as IDLE CYCLES, which is what they were built for: a machine stopped
on a device is not using memory, the same distinction WAIT makes, arrived at from the other
direction. And the devices are told as it happens rather than when the instruction finally
finishes, because a display controller does not stop blinking because the processor is
waiting on a keyboard, any more than a disk stops turning.

A keyboard file can now say NOTHING happened. A zero is a byte no keyboard sends, so it is
free to mean "a moment went by with nobody typing" - which is the commonest thing behind a
window and the only thing a file otherwise could not express. That unlocked the whole waiting
path: three checks that the cursor is lit, then dark half a second later, then lit again,
which is what blinking is.

And Programs/Examples/colours.asm, because the palette had nowhere a newcomer could read it.
It prints the sixteen pairs, prints each one again turned inside out, and then CHANGES ONE by
writing three bytes into the palette - so the difference between using the colours a machine
wakes up with and choosing your own is visible in one program. Its header explains what a
cell is, what the attribute nibble does, why palette entries are four bytes rather than
three, and why video memory has to be reached through the controller.

The manual now says where the palette lives and points at it.

SplitLint found a redundant RSTA in the example, which was worth acting on rather than
suppressing: the zero was already in A from the mode write two lines up, and saying so in a
comment teaches that SETD does not touch A, which is a thing worth knowing.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:39:35 -04:00
AnachronautandClaude Opus 5 761c11a66b Add run-voyager, and make running depend on the disk
The jitter was a stale disk. An image built before the escape sequences came out still had
the old Snake on it, which sends ESC [ H every frame: the console swallows the escape,
because it is below the font's first character, and then draws "[H" as two ordinary letters.
So every frame began two characters to the right and one line further down than the last,
and the board walked down the screen. Not timing at all.

WHAT IS ON A DISK IS WHATEVER WAS BUILT WHEN THE DISK WAS MADE, and a machine whose console
has changed will start that image quite happily. That is the compatibility break we chose
when the parser came out, and it is fine - but it should not be a puzzle, so both run
targets depend on the disk rather than merely using it, and both READMEs say why.

run-voyager boots the same disk on the machine with a screen. It existed only as
EMU=../Voyager in front of run-cosmos, which is not a thing anybody should have to know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:25:18 -04:00
AnachronautandClaude Opus 5 310804e267 Give Snake back the speed its comment promised, and present in step with the display
Two things, one certain and one likely.

THE CERTAIN ONE IS NOT THE WINDOW'S FAULT. Snake's pause loop said "at the emulated rate
this is about an eighth of a second", and it was, when a cycle was one instruction. A cycle
became one memory access, every loop in the machine got dearer, and this one silently
doubled: the game has been running at half the speed it documents ever since, in a terminal
as much as in a window. Measured rather than guessed - the inner loop is a DECA and a BNA,
one byte and three, so four cycles a turn, and a whole run went from 3,848,610 cycles to
1,920,504 when the outer count came down from 256 to 122. Almost exactly half, which is what
the arithmetic said it would be.

That is the cost model change reaching a program nobody thought to re-measure. Worth looking
for others: any loop tuned by eye before that change is running at half its intended speed.

THE LIKELY ONE is the frame limiter. Without the vsync hint, Raylib sleeps towards sixty
frames a second on its own clock, which beats against a display refreshing on its own -
frames shown twice or skipped, and the machine handed an uneven number of cycles each time,
since it takes its budget from the wall clock. The hint puts presentation in step with the
screen. SetTargetFPS stays for a driver that ignores it.

Snake is one byte bigger, because RSTB became INIB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:19:53 -04:00
AnachronautandClaude Opus 5 43a05b3df1 Replace the escape parser with cursor registers
The console had grown an ANSI parser, and that was the wrong shape. ANSI exists because a
screen used to be on the other end of a serial line and a byte stream was the only channel
there was. This screen is memory the program can already address, so reaching it by sending
characters for a state machine to take apart is a middleman for something the machine does
better - and it meant accepting an open protocol somebody else defines, in hardware, with no
natural end to it. Everything else on this machine is registers.

So the console gets three: cursor row at 0x03, cursor column at 0x04, and a command port at
0x05 where 1 clears the screen. Both cursor registers are READ as well as written, which is
the thing an escape cannot do without sending a query and parsing a reply - a routine that
wants to put the cursor back where it found it can now ask.

Clearing is one command against a thousand cells walked one at a time. Snake and Life are
smaller for it: 2,168 bytes to 2,163 and 1,410 to 1,396.

A HOST TERMINAL STILL SPEAKS ANSI, and bridging to the host is the emulator's job, the same
job it does reading standard input. So the escapes are now GENERATED, outbound, for the set
this device chooses, rather than parsed inbound as though the machine were a terminal. The
set cannot grow behind our backs because we are the ones saying it. The cursor is announced
lazily, at the next character rather than at the register write, so setting a row and a
column costs one sequence rather than two.

The console's block widens from three ports to six, which registryTest noticed: it had been
asking about port 0x05 precisely BECAUSE nothing was there, and the console had just moved
in. Re-blessing it would have left it checking nothing, so it asks about 0x80 instead -
clear of the console, the disk, the screen, the controller, and the sound device coming to
0x40.

Six checks in Tests/video.sh swapped from the sequences to the registers, including that the
cursor reads back and that one sent past the edge is clamped rather than refusing. Those
checks also stopped counting bytes from the ends of a file, which had quietly started
measuring an escape the moment the console began announcing the cursor.

SplitLint caught the one thing worth catching in the port: the clear command leaves A at 1
and key mode is also 1, so the second load looks redundant. Acting on it would tie a console
command to a console mode by coincidence, and break silently if either ever moved, so it is
suppressed with that reason rather than removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:08:52 -04:00
AnachronautandClaude Opus 5 bdb2d0d8e6 Keep a key until it is asked for
Input worked at the shell and not in Snake, and that split is the whole diagnosis: the shell
blocks on a read, Snake polls the READY bit. Only the polling path was broken.

RAYLIB CLEARS ITS CHARACTER QUEUE ON EVERY POLL, and a poll happens inside EndDrawing, so a
key survives exactly one frame unless something takes it in that frame. The window presents
sixty times a second and Snake looks about ten, so five keys in six were thrown away by the
next present before the game ever glanced at them. A blocking read presented and then looked
immediately, which is why the shell never noticed.

The window now keeps its own queue, drained from Raylib at every present and emptied only
when the console actually takes a byte. That is what this machine already promises about its
console, and Snake's own comment is the specification: "the console keeps the next key until
it is asked for, so a key pressed while the snake was moving is still there next frame". The
hook was not honouring it.

Asking the queue first also makes the two questions the same question. A poll takes whatever
is waiting and returns at once, and a blocking read takes whatever is waiting, then presents
a frame and looks again - so neither path can see a key the other would have missed.

The queue drops its oldest when it fills, so somebody leaning on the keyboard while a program
ignores it cannot push out what they typed most recently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:50:20 -04:00
AnachronautandClaude Opus 5 556a14b288 Teach the console the sequences the corpus already speaks, and let the status port see the window
Three things Snake found the moment somebody ran it in a window, and all three are the same
kind of mistake: the console grew a screen and kept asking the terminal.

IT COULD NOT CLEAR THE SCREEN. Every program here that moves a cursor does it with ANSI
escapes, because until there was a screen the thing on the other end was somebody's
terminal. The controller drew "[2J" as three letters and left the board underneath. It now
parses them, which is what a video terminal did - a VT100 is exactly this. The whole corpus
uses two, ESC[2J and ESC[H, and the general shape is recognised so anything else is
swallowed rather than drawn: a sequence nobody implemented should leave no marks. Cursor
positioning is in too, since it is the same parse and one line more.

IT DID NOT SEE KEYS FROM THE WINDOW, but did when the terminal behind it was focused, which
is the whole diagnosis in one sentence. Snake polls the READY bit and never blocks, and
consoleFetch - what the status port asks - was polling standard input regardless of whether
a front end had installed a hook. So a window's keys were invisible to every program that
looks before it reads, and a keystroke aimed at the terminal would be picked up instead.

The hook now takes a question. Zero is the status port looking, and must not present or
sleep: a program polling in a loop would otherwise be charged a frame for every glance. One
is the data port blocking, where presenting is exactly right, because a machine waiting for
a key is still a machine somebody is looking at. One value for both would have made either
polling ruinous or waiting dead.

AND IT RAN SLOWLY, which was the same bug wearing a hat: a game that never receives a
steering key is a game that only ever goes one way.

Six more checks in Tests/video.sh, to 32: that ESC[2J clears, that ESC[H goes to the corner
without disturbing what is drawn, that ESC[3;5H counts rows and columns from one, and that
an unknown sequence is swallowed and leaves nothing behind.

The hook itself is still the one thing here the suite cannot reach - it exists only when
there is a window, and this host has no display. It was found by a person playing Snake,
which is where the Test Manual says these go on being found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:43:27 -04:00
AnachronautandClaude Opus 5 6f8ad42277 Fill the window, let it be resized, and make black black
Three things a person looking at a real screen found in five minutes, none of which the
headless tests could have seen.

THE PICTURE FILLED A QUARTER OF THE WINDOW. The window opened at the largest screen the
device can make, doubled, and then drew a 320 by 200 mode at that same doubling - so three
quarters of it was bezel. It now takes the largest whole-number scale that fits: the two
modes are exactly a factor of two apart and the window opens at twice the larger, so both
fill it exactly, at four and at two. Changing mode changes how sharp the screen is rather
than how big it is.

Whole numbers on purpose. A 320 by 200 picture stretched by 2.7 has some rows twice as tall
as their neighbours, which on eight pixel glyphs is the difference between text and mush.

THE WINDOW WOULD NOT RESIZE. It does now, and the picture rescales to whatever it becomes,
still in whole pixels and still centred. How big somebody wants a screen is not the
machine's business.

AND BLACK WAS NOT BLACK. Both the paper and the bezel were tinted towards green, on the
theory that a phosphor never was neutral. On a real screen that reads as a fault rather
than as character: a background that is nearly black looks like a background that failed to
be black. Paper is black now and ink is a neutral grey, because a default should be the
unsurprising thing - anything with a point of view about colour is 254 palette entries away
and belongs to a program. The bezel is a clearly lighter grey, so what is left over when
the window's shape does not match the picture's looks like a bezel rather than like more
screen.

The two checks that name the waking colours name the new ones. That they had to change is
the check working: they say what they depend on rather than assuming it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:32:27 -04:00
AnachronautandClaude Opus 5 773b0f8add Put CosmOS on the screen without changing a line of it
The console is now a display controller as well as a port: it owns a font, keeps a cursor,
handles newline, carriage return, backspace and wrapping, and scrolls. That is an ordinary
kind of chip - it is what a video terminal's character generator did - and it is the reason
this rung needed no changes to CosmOS at all. CosmOS already writes bytes to port 0x00.

It writes to BOTH the screen and standard output, which is deliberate. A machine with a
screen and a serial line is an ordinary machine, the emulator's standard output is that
serial line, and one console drives both. It is also what keeps all 165 recorded results
passing under Voyager, and what makes --screen work on the plain SplitBit: there is one
console and it drives everything it has.

Scrolling moves the video device's origin and no memory. The row arriving at the bottom is
cleared because the map is a ring and it holds what was there 128 rows ago; the rows going
off the top are not, and that is a hundred rows of scrollback nothing had to keep. The test
reads the register back rather than looking at the screen, because a console blitting rows
instead would look identical and cost twelve percent of a frame for every line printed.

The font is vendored from Hatchet-GPU with a note saying where it came from, since that
repository is not part of this one. 135 glyphs in ASCII order, which is the thing that makes
it worth keeping - PETSCII's whole inconvenience was that its order was not ASCII's, so a
machine using it needed a translation table in front of every string. Here the machine
subtracts 32. It is stored one bit a pixel and expanded into tile memory at reset: 1,088
bytes against 16 kilobytes.

Voyager gets a keyboard. A window has no standard input, and a machine blocking on it inside
a frame would stop drawing and stop answering, so a front end with a window installs a hook
that the console calls while it has nothing: it keeps the window alive and hands back a key.
The hook has to tell "nobody has typed yet", which happens sixty times a second, apart from
"the window has gone", which is the end of input - one value for both would have made the
first keystroke look like a closed machine. In line mode the console echoes what it is
given, because there is no terminal behind a window to do it and that was always the
terminal's job.

Tests/video.sh grew from 14 checks to 26, half of them about the console rather than the
device: those programs ask the video device for nothing and write bytes to port 0x00 like
every SplitBit program always has. Verified by breaking two things - removing the scroll
failed exactly the two checks about scrolling, and removing the cursor advance failed
exactly the three that depend on it.

Two video checks had quietly depended on palette entry 0 being black, which stopped being
true the moment a machine woke up able to show text. They now set what they are about to
look at, and a new check pins the waking state itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:26:27 -04:00
AnachronautandClaude Opus 5 83623a3df3 Give the Voyager a screen
A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way
the disk's buffer is. The CPU writes cell indices and the device turns them into pixels,
which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles,
a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that
changes two cells writes four bytes. The cost of a screen becomes the number of cells that
changed rather than the number of pixels on it.

Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and
each picks independently out of 256 colours, with no per-cell limit of the kind that made a
Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to
every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of
sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the
nibble at zero and gets them. Neither use costs the other anything.

Two decisions are arithmetic rather than taste, and both come from the machine having no
multiply. A map row is a page whether the mode fills it or not, so a cell address is the row
number as the high byte and the doubled column as the low byte with no arithmetic at all;
otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And
a palette entry is four bytes rather than three, so entry n is at n times four, a shift.

THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling
moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside
one bank, which is twelve percent of a frame even with the controller widened, and a program
printing one page would spend six frames shuffling memory. It is now one port write - and
the rows that scrolled off are still there, which is where a terminal gets scrollback it
never had.

The device is part of the machine rather than part of the window. It renders into a buffer
that is a pure function of video memory, so the same program draws the same picture with
nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries
take --screen, which saves a PPM when the machine stops, and that is what makes a screen
checkable on a host with no display at all.

Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image,
because a recorded image would say "something changed" and leave which of the palette, the
tile, the attribute, the map or the scroll register broke to be found by hand. Verified by
breaking three things in turn: the additive nibble failed exactly one check, the scroll
origin exactly two, and moving every cell one pixel sideways exactly the four about
placement.

Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported
itself as wrong for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:11:13 -04:00
AnachronautandClaude Opus 5 e3ef25e3b3 Split the machine from its front end, and add Voyager
The Segan Voyager is the same SplitBit with a screen and a speaker instead of a terminal,
and this is the rung that makes there be two of them at all. Everything that is actually
the machine - the CPU, the controller, the devices, the run loop, the reporting - moves to
machine.c, and each front end brings one file of its own. emulator.c is now sixty lines of
argument handling and a three line loop.

The machine runs in SLICES rather than to completion, because that is the cut a window
needs: run a slice, present a frame, run another. A terminal runs slices until the machine
stops. Both loops are three lines, which is why the cut is there rather than anywhere else.

At this stage Voyager's window is empty. There is no video device yet and inventing a
temporary way to draw would mean building something to throw away.

PLAIN MAKE STILL WORKS WITH NO GRAPHICS LIBRARY. Raylib is probed by compiling and linking
against it rather than by looking for a file, because a header with no library behind it
passes a file check and then fails at link time. Where it is missing, make says so once and
builds everything else - the machine, the assembler, the disk tool, the linter and the whole
suite. A project about a small understandable CPU should not need OpenGL to run its tests.
That nearly broke here: make strict globs Source/Emulator/*.c, so it would have tried to
compile voyager.c and failed on precisely the machines the split exists to support, and this
machine has Raylib so nothing would have caught it.

Tests/voyager.sh runs the WHOLE MANIFEST through Voyager and holds it to the recorded
results SplitBit is held to. Not that the two look alike: that one satisfies every recording
the other does, byte for byte, exit status included. It reuses run.sh, which now takes the
machine from SPLITBIT_EMULATOR, rather than keeping a second copy of the runner that would
drift. Voyager not being built is not a failure - it says so and passes.

Verified both ways. Made Voyager print one extra line, and 114 of 165 failed: exactly the
tests that run the emulator, with the 51 assemble-only and xfail cases correctly untouched.
Removed the binary, and the script skipped. Built with HAVE_RAYLIB=no, and everything else
still built and checked clean.

--headless is taken out of the arguments in voyager.c rather than in the shared parser,
which should not learn about a window only one binary has. It exists so the suite can run
this binary at all: a front end that could only be exercised by a person looking at it would
be a front end nothing checks.

loadFile takes a const char * now, which it always should have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 21:57:09 -04:00
AnachronautandClaude Opus 5 4c3eac8d9c Widen the memory controller's path to sixteen bits
The controller now reaches bank memory two bytes at a time, so a transfer whose source,
destination and length are all even moves two bytes a cycle between banks and one within
a bank - twice what each was. A 256 byte block between banks falls from 257 cycles to 129.

Alignment is required all three ways because a word is read at an even address and written
at an even address; an odd anything would mean shifting bytes across word boundaries to
line them up, which is a different design. A misaligned transfer falls back to the byte a
cycle it cost before, so nothing already written got slower.

THE CPU DOES NOT CHANGE. It still sees eight bits, a Data Pointer still addresses a byte,
and no instruction means anything different. This is a peripheral getting faster, which is
why it is worth doing now rather than after more is built on top of it.

The rule is deliberately visible rather than smoothed over: aligning a buffer costs nothing
and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid.

Tests/cycles.sh is new, and is the test the Test Manual has always said this kind of change
would need - run.sh strips the cycle count from every recorded result, so nothing else in
the suite can see any of this. It pins the RATE rather than a total: each case runs twice
from programs whose instructions are identical but for the byte written to the Command
port, once asking for the transfer and once for GuardOff, which costs nothing beyond the
port write. The difference is the transfer and nothing else. Verified by disabling the
widening, which failed exactly the three aligned cases and left the five misaligned ones
passing.

The Programming Manual gains a section saying what a transfer costs, which it never said at
all - it only promised a transfer does not wait, which is a different claim and could be
read as promising it is free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 21:30:12 -04:00
AnachronautandClaude Opus 5 c3c2451afe Say what the strict build and the sanitizers actually establish
Three claims in the Test Manual were true enough to pass and loose enough to mislead.

The headline said the two assemblers produce identical bytes and the two filesystems
produce identical disks, with no qualifier. That reads as universal and is not: it is
evidence about the corpus assembled and the operations performed. The detailed sections
already said so; the headline now says so too, because a document arguing about what
evidence is worth cannot overstate its own.

"Compiles under strict ISO C" omitted -D_XOPEN_SOURCE=700. The check is strict C11 with
the POSIX interfaces the code uses explicitly selected - realpath, strdup, dirname and
getopt - not freestanding ISO C, and the flag is part of the check rather than a hole
in it.

And the sanitizer section leaned on AddressSanitizer's junk fill, which is a toolchain
default this build does not configure and which buys almost nothing here anyway: there
are six heap allocations in the whole repository, all in the assembler, the largest a
deliberate calloc, and the machine's own memories are static arrays the sanitizers
neither fill nor bound-check. That last part is the overrun blind spot seen from the
other side, so it now points at it. The same claim is corrected in the makefile, where
it originated.

Found by ChatGPT reviewing the manual.

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

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

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

Found by ChatGPT reviewing the manual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 18:07:35 -04:00
AnachronautandClaude Opus 5 999451e935 Ignore External, which is other people's repositories
soundThing and Hatchet-GPU are kept in the working tree to be read, not built: one is
where the synthesizer comes from, the other is where the 8x8 font and the scroll register
idea come from. Neither belongs to this repository. They carry their own .git directories,
their own build outputs and three compiled binaries between them, and a plain
'git add -A' would have taken all of it.

What this repository takes from them will be vendored deliberately, with a note in the
file saying where it came from and what changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 17:49:38 -04:00
AnachronautandClaude Opus 5 d6bc416698 Write the Test Manual, and make the suite check it
The test system had grown to seven scripts making five genuinely different
kinds of claim, and nothing said which was which. A recorded transcript and a
byte-for-byte comparison against a second implementation both print [ok  ] and
are worth wildly different amounts, so the fourth manual exists to say so: what
each script can and cannot answer, why every determinism rule is there, how to
add a test, and - the part written nowhere else - where the suite is blind.

That last section is the reason for the document. Three buffer overruns into
adjacent variables were all found by a person using the machine and none by the
suite, the sanitizers cannot see them because emulated Data Memory is one
legitimate host array, and there is no second opinion about the CPU at all. A
document listing only strengths teaches the wrong lesson.

The bullets describing each script move out of the README, so docs.sh now reads
the manual for them, and five more numbers in it are settled from the source
rather than trusted: the shape of the manifest, the xfail count, how many
fixture disks makedisks.sh builds, how large the lint baseline is, and the tool
count in either document. Each of the new checks was broken on purpose and
watched to report before being kept, which is the discipline the manual itself
argues for.

Also drops the stale "70 instructions" from instructiontable.py's docstring.
There are 72, and a number that carries no meaning is better removed than
corrected.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two smaller things. Snake sets the console to line mode and then exits with
zero, and the linter flagged the second RSTA as redundant - an exit status
and a console mode, equal by accident, which is the class that must never
be collapsed. And the README still taught answering by writing into the
frame, three months of habit that SRET replaced yesterday; that section is
gone and the one describing SRET stands in its place.
2026-08-27 19:16:21 -04:00
Anachronaut b4206673a6 The clean install disk is one the machine can start itself from
make run-cosmos handed the emulator a boot image AND a disk, so it took the
direct path every time: memory placed from outside, nothing on the disk
consulted about it. The whole boot chain was built and tested and then not
used by the one command that runs the system.

The disk is formatted with a boot area now, stage two goes into slot zero
as raw bytes, and the system is an ordinary file at /System/Boot/cosmos.bin
- which is what boot.cfg would choose between if there were one. There is
not: stage two falls back to that name when the file is missing, and a
clean install with nothing to configure is the right default.

run-cosmos names no image, so the machine starts itself. The old behaviour
is run-cosmos-direct, which is worth keeping and worth naming honestly: it
is what a debugger does, and it is what to use when the thing being
debugged is the boot chain, since it skips the boot chain.

/Source gains stage1.asm and stage2.asm, so the machine can rebuild what
starts it. Everything stage two includes is already in /Lib, so Asm
stage2.asm on the machine produces the bytes a boot slot takes. Stage one
is the exception and always will be: it is the ROM, and the one part of
this that a disk cannot replace.

Second slot left empty, which is what makes replacing the first survivable.
2026-08-27 18:45:52 -04:00
Anachronaut f1cc2e56b2 The last handler that knew a frame offset
handleReadLine used DP1 for the frame and the conversion only looked for
DP2, so it kept the whole dance - and the dance had become a no-op that
looked like work: MVQA copied Q into A, A was written over the saved Q, and
RETI restored it, which is where it started. readLine leaves the length in
Q and SRET keeps Q, so the answer simply stands.

No handler in CosmOS knows what an interrupt frame looks like now. The
0d02 and 0d05 offsets still in the file are structures - an SBEX header, an
entry in the instruction table - and not frames.
2026-08-27 18:30:28 -04:00
Anachronaut 61a80ae13d Put the manual's sections back in opcode order
The user's edit. Moving branches to 0x60 and subroutines to 0x70 left their
sections sitting where they used to be numerically, between the ALU and the
registers, so the manual read in an order the opcode map no longer did.

The list has always been arranged by opcode sequence rather than by
importance or by how often a thing is used - arbitrary, and now canon.
2026-08-27 18:27:46 -04:00
Anachronaut c8c9f0b363 SRET: a handler answers the way a subroutine does
CALL saves A, B and Data Pointers 0 to 2 and nothing else, which is exactly
why Q and DP3 are how a subroutine hands something back. An interrupt saves
all of it, so a service with an answer had to reach into its own frame and
un-save two fields by hand:

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

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

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

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

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

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

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

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

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

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

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

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

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

The monitor's recorded disassembly moved by exactly the bytes it should:
18 became 72 wherever SWI appears, with SETD and INIB untouched and every
disassembled line still reading the same.
2026-08-27 18:05:54 -04:00
Anachronaut ce2a2cd7e6 Settle is a program, and a machine with no fallback still starts
The boot state opened a loop that could not be closed from inside: the
machine said "settle it to try again" and gave you no way to do so. Settle
closes it, in 349 bytes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  system
  system /System/Boot/bare.bin

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

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

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

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

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

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

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

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

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

Three things in it worth knowing:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A CLEAN RUN SAYS SO:

  No style warnings: 121 files checked against 12 rules.

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

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

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

One thing to know for next time: the rule name was inserted before the line
number at all twenty one call sites, and the signature was changed to match
rather than the twenty one call sites being fixed. (path, rule, line) reads
no worse than (path, line, rule) and one edit has fewer ways to go wrong
than twenty one.
2026-08-26 21:00:21 -04:00
Anachronaut 9c144469b4 Take the SplitLint findings that are one operation, leave the rest
Twenty four more sites, and the interesting part is which ones were left
alone. A rule emerged while reading them and it held all the way through:
apply where the repetition is INSIDE one operation, skip where the author's
own structure says it is a new thought, and never where two equal values
mean different things.

Taken:

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

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

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

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

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

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

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

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

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

Comments on either half of a replaced pair are carried onto the
instruction that replaces them, so nothing anybody wrote was lost.
2026-08-26 17:53:10 -04:00
Anachronaut c146d98588 Stop the linter recommending a change that a faster helper would break
SplitLint knew that CALL restores A, B and Data Pointers 0 to 2, so a
pointer set before a call is still set after it. That is true, and it made
the tool give advice that was correct today and unsafe to take.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Written with the user while I was away; my part is the suppression
mechanism, the harness rewrite, and the three marks in the test programs.
2026-08-26 17:23:17 -04:00
Anachronaut c216c83e12 The filesystem waits for the disk instead of asking it repeatedly
sbfsWaitDisk spun on the status port. Read ahead had already hidden about
three quarters of the latency, and what remained was still 11.5% of Type
over a 14K file on a ten thousand cycle disk - all of it memory traffic
spent finding out that nothing had happened yet.

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

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

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

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

The first version cost 660 cycles more at latency zero because it read the
status port again on the way out. AND writes to Q and leaves A alone, so
the status was already there - which is what the original said in its own
comment, and what I stopped believing while rewriting around it.
2026-08-26 11:14:17 -04:00
Anachronaut c3188ed657 Seventy becomes seventy one: a machine that can wait
HALT is terminal - stepCPU returns at once when the Halt Flag is up, so a
halted machine does not execute, service devices, or take an interrupt -
and that has to stay true, because every test ends with a halt and "halted"
is how a program says it has finished. The consequence was that SplitBit
had no way to wait at all. Every wait was a spin, and a spin is bus
traffic: 11.5% of Type over a 14K file on a disk of ten thousand cycles,
after read-ahead had already hidden three quarters of the latency.

WAIT is 0xFE, one byte, no operands, sitting under HALT where the
instruction that almost stops the machine belongs. Three decisions in it:

- A line already standing means there is nothing to wait for, so WAIT does
  nothing. That is what makes test-then-wait race-free.
- Any line ends the wait, masked or not, so a program can sleep on a device
  it has no handler for and read its status afterwards. Masking says who
  answers a request, not whether it happened.
- A line that wakes the CPU without being dispatched is taken down by the
  WAIT. Left standing it would be found by the next WAIT, which would
  return at once - the program would spin exactly as before while looking
  as though it slept.

Waiting is NOT a Status bit, and that is the trap avoided rather than a
gap: Status rides into the interrupt frame and comes back out, so a machine
interrupted mid-wait would return from its handler still waiting, and wait
again for what it had already been given. An internal field instead.

Idle cycles are counted apart from bus cycles and the halt line says so
when there are any, which is what makes the difference observable at all -
with the line-clearing removed the total moves by ONE cycle, 20,100 against
20,099, and only the idle half changes, halving to 9,976. A test on
totals could never have seen it. Tests/terminal.sh asks that question,
being the file for things a recorded output cannot see, and fails with the
clear removed while "both reads finished" still passes.

Three collisions, all found by building it:

- 0xFE was the assembler's "not an instruction" sentinel. getOpcode now
  answers a negative NOT_AN_OPCODE, which is outside the range of every
  possible answer instead of inside the unused part of it.
- 0xFE was also what faultTest and faultResumeTest executed to provoke a
  fault. They now use 0xFD and say why, because they did not fail when it
  became an instruction - they HUNG, having started sleeping instead.
- Keys.asm has had a label called "wait" for a year, and mnemonics are
  matched uppercased. What that reported was "Branch without label" at the
  BRQ thirty lines away. The assembler now refuses a label that is already
  an instruction, at the label, by name; every instruction added takes a
  word out of the space of label names, so this will happen again.
2026-08-26 11:11:25 -04:00
Anachronaut 6b41354f8f Delete a comment describing a design that was removed, and check the rest
Two comments in the native assembler survived the changes that made them
false, and both are the kind that misleads rather than merely ages.

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

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

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

The list of design strengths at the end went with it. It was a reader
describing what the format gets right, which the README says in its own
words and at more length; keeping a second copy in a file headed 'delete
me' would only be a second copy to keep current. It is in the history if
anybody wants to read it back.
2026-08-25 23:47:12 -04:00
Anachronaut ce0f18f4ef Refuse a directory whose last entries cannot be named as a parent
A parent is an entry index PLUS ONE in two bytes, so entry 65535 has no
parent number: adding one wraps to zero, and zero is the root. Eight
entries to a block, so 8192 directory blocks reaches it and SplitDisk
formatted that happily.

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

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

Three checks, all of which fail with their guard removed. The machine's
disk claims the size rather than having it, so the test image is 64 blocks
that lie rather than sixteen megabytes that do not - mounting is refused at
the geometry, which is read out of block 0.
2026-08-25 23:47:02 -04:00
Anachronaut 634650cab9 The path limits are settled: leave them, and say what they are
Measured rather than adjusted. The longest path on a full install is 21
characters against a native cap of about 117, so neither that nor
SplitDisk's 512 binds anything, and both can be raised if that ever
changes. Looking for the evidence turned up the limit that did bind - the
prompt's 127 bytes, fixed in the commit before this one.
2026-08-25 23:35:40 -04:00
Anachronaut 2b5506ee70 Stop the prompt writing off the front of its own buffer
The prompt is the working directory's path, worked out each time by walking
the chain of parents up to the root. The names arrive deepest first, so
they are written backwards from the end of a 127 byte buffer - and nothing
bounded that walk.

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

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

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

The three path limits are written down in the README now, including which
one actually binds. The other two do not: the longest path on a full
install is 21 characters.
2026-08-25 23:35:33 -04:00
Anachronaut 0c240f7ad3 The temporary namespace is no longer a note
Two left: one path length both implementations agree on, and a bound on
directory entries that keeps every one of them nameable as a parent.
2026-08-25 23:22:43 -04:00
Anachronaut ce8fb721fe Say a temporary is one in the entry, not in its name
Saving something that already exists writes a temporary, deletes the
original and gives the temporary its name, so that nothing is lost if the
writing fails. The temporary was told apart from a real file by being
called sbfs.part or sbfs.out - and those are legal names. Starting a save
deleted whatever answered to one as stale scratch, so saving anything at
all in a directory destroyed your own file of that name there, silently.

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

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

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

No version bump: a committed file never carries the bit, so a disk this
writes is byte for byte the disk the old code wrote, which the whole-image
comparisons confirm. Only the wreckage differs, and older code reads that
as an ordinary file - which is what it did before.
2026-08-25 23:22:37 -04:00
Anachronaut d6cbbb5034 Strike the three review items that are now fixed
The streamed commit is bounded, the memory map is checked against itself,
and the save guarantee says what it is. Three left: the temporary-file
namespace, one path length both implementations agree on, and a bound on
directory entries that keeps every one of them nameable as a parent.
2026-08-25 22:56:21 -04:00
Anachronaut 9e2aa0122e Say what a safe save actually promises, and where it stops
Both sbfsSaveFile and sbfsStreamDone write a temporary, delete the original
and rename the temporary onto its name, and the README explained why that
order and not the obvious one. It never said what the resulting guarantee
is, which invited the stronger reading: "safe save" sounds like it survives
anything.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 22:36:22 -04:00
AnachronautandClaude Opus 5 d4cba36c5e Devices that take time, and a filesystem that waits for one
The disk's status has always had a bit meaning "still going", and the header
beside it has always said to honour it. Nothing did, because nothing could: the
host finished the transfer inside the instruction that asked for it, so the bit
could never be seen up and asking about it was asking about something that
cannot happen.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-25 21:29:57 -04:00
AnachronautandClaude Opus 5 e0cf0a9a25 Charge the memory controller for the memory it moves
A blit cost ten cycles, which were the five port writes that set it up. The
quarter of a kilobyte that moved cost nothing, and no hardware moves a quarter
of a kilobyte for nothing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two bugs, both a pointer meaning two things:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things this cost, all found by running it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things this had to get right:

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 18:16:27 -04:00
Anachronaut 12cb489268 Minor typo fix to assembler manual. 2026-08-21 17:09:34 -04:00
AnachronautandClaude Opus 5 bd94059c08 Put the screenshot at the top of the README
The picture does what three paragraphs of prose were doing, and does it
first. It has the whole loop in one frame: Asm.sbx assembles hello.asm into
hello.sbx, the shell loads that and runs it, and then the monitor
disassembles what is at 0x2000 - which is the program the machine wrote for
itself at the top of the same screen.

The caption points at that last part, because it is the thing an eye slides
past. The disassembly is not of an example; it is of the output.

Two notes on getting it right rather than nearly right. The file is
Media/CosmOS.png and not the cosmos-screenshot.png it was described as, so
the link would have been dead on arrival - checked by looking rather than by
typing what I was told. And the caption first said the disassembled program
was written "six lines earlier", which was wrong by about four; it now says
"at the top of the screen", which cannot drift.

docs.sh already covers it: the link check treats an image like any other
relative link, and moving the file out makes the suite say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 17:06:46 -04:00
AnachronautandClaude Opus 5 9690ecf21f make sanitize runs the whole suite, not half of it
It built all three tools with the sanitizers and then ran run.sh and
terminal.sh only. So SplitDisk was compiled sanitized and never once
exercised, and native.sh - which drives the assembler and the emulator
harder than anything else here, through two self-hosting builds and a fixed
point - was skipped entirely.

Those are precisely the places worth watching: block and tail arithmetic on
disk images, and buffer indexing in two assemblers, one of which was written
this week.

disk.sh, native.sh and docs.sh now run under the sanitizers with the rest.
Timed first rather than guessed at: 0.41s, 6.05s and 0.14s, against a whole
sanitize run of 33 seconds. All three pass, which is worth knowing given
none of them had ever been run this way.

The README said sanitize ran "the suite", which was generous. Now it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 16:51:03 -04:00
AnachronautandClaude Opus 5 8ace20629b Say what the tests need, and count the tools correctly
Two nitpicks from review, and one of them was only half right, which is why
each was checked rather than swept.

THE TEST DEPENDENCIES were nowhere. The README said what building needs and
stopped, so somebody with a C compiler and nothing else would get through
'make' and fall over on 'make test'. It needs bash and Python 3 - two of the
checking scripts are Python, and one of those opens a pseudo-terminal - plus
stty, timeout and the usual text utilities.

"BOTH TOOLS" WAS WRONG IN TWO PLACES OUT OF SIX. 'make sanitize' builds the
default target, which is all three, so the README and the makefile both
undercounted what they rebuild.

The other four are right and were left alone. assembly.h says both tools
have to agree where the vector table begins, and bootstrap.c and cpu.c say
they share the boot image format and the vector layout - in all three cases
that is the emulator and the assembler, and SplitDisk has no opinion about
any of it. The makefile's POSIX comment is the same story: SplitDisk uses
not one POSIX interface, so it names the two that do rather than counting to
three.

Media/ is a home for the screenshot that is coming, with a note saying what
belongs in it and that docs.sh will catch a link to something removed from
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 16:47:13 -04:00
AnachronautandClaude Opus 5 4e5710a1e6 Make the README's references clickable, and check that they land
The two manuals and the CosmOS README were named in prose and nothing else,
so reading about them and getting to them were separate acts. They are links
now, along with every directory in the repository map, which is the other
place somebody reading that page wants to click.

THE MANUALS HAVE SPACES IN THEIR NAMES, so the links carry %20. That detail
is why this is checked rather than eyeballed: a link with a raw space in it
points at a file that exists, so nothing about the filesystem is wrong -
the renderer just stops at the space and the link goes nowhere useful.

Tests/docs.sh now walks every relative link in every tracked markdown file
and complains about both failures: a target that is not there, and a target
with a space that should have been encoded.

Both verified by breaking them. The first attempt at that verification was
itself wrong - I removed the %20 and the check passed, because the file
really does exist under that name. That is what showed the two failures are
different things and both need catching.

13 links, all landing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 16:29:21 -04:00
AnachronautandClaude Opus 5 3f95056eec Three papercuts a visitor would hit first
Found by a review, all three confirmed by trying them rather than by
reading.

1. THE STRICT BUILD CLAIM WAS FALSE. The README says the sources build clean
   under -std=c11 -pedantic with -Wall -Wextra, and they did not: realpath is
   an XSI interface, and _POSIX_C_SOURCE=200809L does not reach it, so the
   assembler would not compile. The ordinary -Os build never saw it, because
   without -std=c11 the compiler's own default declares realpath anyway.

   _XOPEN_SOURCE=700 is POSIX.1-2008 plus XSI, and covers every file on its
   own. Narrowed by compiling each source with each candidate macro rather
   than by adding one and hoping.

   And 'make strict' now checks it, as part of 'make test'. The README makes
   a claim somebody may check by typing it, so the suite types it. Verified
   the check bites by putting the old macro back.

2. THE COSMOS README HAD NOT CAUGHT UP WITH THIS WEEK. It said there were no
   breakpoints and proposed writing a spare byte over an instruction - two
   commits after SWI osBreak was built, which does it without overwriting
   anything and is described correctly further down the same file. Its
   Current Scope said there was no native assembler and that self-hosting
   was the intended long-term milestone. And the streaming section spoke of
   a machine "one day going to assemble itself".

   All three now say what is true. Self-hosting is described as done, with
   what is left of it named: a linker, and an editor that knows what
   assembly is.

3. 'make run-hello' DID NOT WORK, and it is the one command the makefile's
   own header advertises. It was a pattern rule against $(BUILD)/%.bin,
   which worked while every program sat at the top of Programs/ and broke
   the moment they were filed into Examples/ - which I did, four commits ago,
   without trying it.

   The name is looked up among the programs now, so it is the program's name
   rather than its path, and an unknown one lists what there is instead of
   saying "No rule to make target".

The same sweep was run across all four documents for other claims this week
invalidated. The remaining "not yet" phrases are about faults that genuinely
are not defined yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 16:17:24 -04:00
AnachronautandClaude Opus 5 7e8639489b Take the direction out of the last cross references
Includes two edits of Anachronaut's: dropping a "See the Assembler Manual"
from text that is now IN the Assembler Manual, and tightening a sentence
about when a vector number has to be agreed.

The first of those was mine to have caught. Moving "Loading A Program From
A Disk" into that manual turned a cross reference into a document telling
you to go and read itself. I checked for dangling references before the
move and found none, because I checked for references by SECTION NAME and
not for references to the manuals themselves.

Checked properly now, both directions: no other document refers to itself,
and nothing that moved says "above" or "below" about something that ended up
in a different file.

The three remaining directional references are gone with them - two that
were correct today and one, "See Numbers You Write Down below", pointing at
a section twenty four lines further down that nothing guaranteed would stay
there. A reference that carries a direction is a reference that goes wrong
the next time anything moves, and this week moved a great deal.

There are none left anywhere in the repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 15:37:08 -04:00
AnachronautandClaude Opus 5 f4bc587d0a The C assembler names its output for the format, not always .bin
It always wrote .bin, whatever it had built. So assembling a loadable
program without -o produced Say.bin containing SBEX - a boot image name on a
file the machine cannot boot, in a repository whose whole convention is that
a .bin is started from and a .sbx is loaded.

    Successfully wrote SplitBit boot image to "hello.bin".
    Successfully wrote SplitBit loadable program to "Say.sbx".

programIsLoadable() already existed and is already what decides which
writer runs; the name now asks it too. Nothing in the build depended on the
old behaviour, because everything that assembles anything passes -o.

THE ASSEMBLER THAT RUNS ON SPLITBIT ALREADY DID IT THIS WAY. Two assemblers
naming their output differently from the same source is exactly the kind of
difference that wastes an afternoon, and the newer one was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:54:51 -04:00
AnachronautandClaude Opus 5 b6004bdcde Say "boot image" where that is what is meant
"Binary" was doing three jobs. It meant an SPBT file that the machine starts
from; it meant whatever the assembler happened to produce, which is now
either that or a loadable program; and it meant a compiled host tool. A word
that means three things means none of them, and the first of the three has a
name already - this project has been calling them boot images for a while
and the manuals had not caught up.

  Where it means an SPBT file       -> boot image
  Where it means either output      -> output
  Where it means a host executable  -> left alone
  Where it means base two           -> left alone

The user facing messages move with it:

  Error: No boot image specified.
  Usage: ./SplitBit [OPTIONS] <boot image>
  Error: This is not a SplitBit boot image.
  Error: This boot image is in format version 2, and this emulator reads 1.
  Successfully wrote SplitBit boot image to "hello.bin".

The assembler's own help was the interesting case. Its -o writes either
format, so "the binary" there was never right - it is "the output" now, and
the message that names the format is the one that says which it wrote.

No recorded output contained the word, so nothing needed re-blessing.
Checked before starting rather than after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:50:03 -04:00
AnachronautandClaude Opus 5 306b4dce92 Give the Programming Manual a title, and send the boot image format away
Last of the four. What was left after the reorder was a document whose first
heading was "General Description" doing a part title's job without being
one, and a section called "Input and Output In the Emulator" that held two
console ports, a worked program, and a file format.

  A title and an opening that says what this document is FOR, and what the
  other two are for, so a reader who wants the operating system or the
  language knows immediately they are in the wrong file.

  "General Description" is "The Machine", which matches the three part
  headings the reorder gave the rest.

  "Input and Output In the Emulator" is "Making It Print Something", which
  is what the section is: port 0, and the shortest program that uses it.

THE BOOT IMAGE FORMAT MOVES TO THE ASSEMBLER MANUAL, beside the loadable
program format, for the reason SBEX went there: it is a thing the assembler
WRITES. It is fair that the emulator reads them too - both tools speak it,
the way SplitDisk and sbfs.asm both speak the filesystem - but only one of
them makes one.

And it is called a boot image now, in the text as well as the heading. That
is what this project has been calling these files for a while; the manual
was still saying "binary", which now means either kind of output file and so
means neither.

A CHECK THAT GOT BETTER BY BEING SPLIT. The hello world program and the hex
dump of it were both in the Programming Manual, and docs.sh compared them
with each other and with the assembler. The program stays with the machine,
where the reorder put it just after the instruction list; the dump goes with
the format it demonstrates. So the check now settles THREE things against
each other: what one manual prints, what the other prints, and what the
assembler actually makes. Verified both ways - a wrong byte in the dump, and
the anchor renamed.

The manual is 692 lines and four parts. It was 1,116 lines and nineteen flat
sections when this started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:44:11 -04:00
AnachronautandClaude Opus 5 e594f44cce Reorder the Programming Manual so it can be read from the front
The order was the order things were written in. A newcomer read the register
list and the very next heading was the vector table - an interrupt
mechanism, before a single instruction had been shown. The list of
instructions was section eighteen of nineteen, and how to make the machine
print something was dead last.

Four parts now, and each answers a question the one before it raises:

  General Description        the registers, the memories, the flags
    Naming a Data Pointer
    List of Instructions     was 18th, and nothing before it could be read
                             without it

  Making It Do Something
    Input and Output         was 19th and last. It carries the hello world
                             program, which now arrives on the heels of the
                             instruction list rather than after everything
    The Console
    The Stack Pointer, Set By Hand

  When Something Else Wants Attention
    Interrupts               the mechanism before the table, which was the
    The Vector Table         other way round and made no sense that way
    Hardware Interrupts
    Faults
    Refusing

  What A Machine Is Made Of
    Devices
    Asking What Is There
    The Memory Controller
    Storage

Nothing is rewritten. Every section is the text it was, in a different
place, so the diff is a move and can be read as one.

CROSS REFERENCES NO LONGER SAY WHICH WAY TO LOOK. "See The Console below"
was true until this commit and false after it, and three of them flipped at
once. The name is enough to find a section with, and a reference that
carries a direction is a reference that goes wrong the next time anything
moves. There will be a next time.

Left for the last commit of the four: "General Description" is doing the
work of a part title without being one, and "Input and Output In the
Emulator" now holds a worked example and a file format as well as two
ports, so it wants a better name or a split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:39:56 -04:00
AnachronautandClaude Opus 5 fa3982dbd9 Move the CosmOS third of the Programming Manual to CosmOS
386 of the manual's 1,116 lines documented an operating system rather than
a machine. The split inside that file was never tutorial against reference;
it was the machine against the software that happens to run on it.

  What A Program May Ask The System For   129  -> CosmOS README
  Programs That Come With The System       111  -> CosmOS README
  Reading And Writing The Filesystem        64  -> CosmOS README
  Loading A Program From A Disk             52  -> Assembler Manual
  The Console Library                       25  -> CosmOS README

The services are the clearest case: a hundred and thirty lines describing
what CosmOS offers a program, in the manual for a CPU that has no operating
system of its own. A different system on the same machine would offer
different services and that section would be wrong for it.

The loadable program format goes to the Assembler Manual instead, because
SBEX is a thing the assembler WRITES. Nothing in the CPU knows what it is.

The Programming Manual is 716 lines and fourteen sections now, all of them
about the machine.

TWO DUPLICATE DESCRIPTIONS COLLAPSED INTO ONE EACH. The application list
existed in both documents in different words, and the CosmOS copy had gone
stale - no Break, no Stream, no assembler - because only the manual's copy
was checked. Moving the checked one in and deleting the other leaves one
list, and docs.sh follows it.

The second was made by this commit and caught while reading the seams: the
CosmOS README already had a service table, so the move briefly produced two.
That section now says what services are for and points at the one table.

Renaming a section as it moved: "Reading And Writing The Filesystem" is
"The Filesystem Library", which says what it is and reads beside "The
Console Library".

docs.sh follows all five, and each was verified by renaming the heading in
its new home and reading the complaint. The README and the CosmOS README
both described what the other manuals cover, and both were wrong the moment
this landed; they say the division out loud now, since it is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:35:18 -04:00
AnachronautandClaude Opus 5 460a687939 Make the manuals plain ASCII, and check that they stay that way
"All files must be plain ASCII, the user's tooling doesn't support Unicode"
is a standing rule of this repository. Nothing enforced it, so it drifted:
39 em dashes and an ellipsis had collected in the two manuals, every one of
them typed by something that helpfully substituted a nicer character. The
spaced em dash becomes a spaced hyphen, which is what the source comments
and both READMEs use for the same job.

Tests/docs.sh now checks every tracked file and says which line and which
character. Verified that it bites.

THE CHECK READS git ls-files NUL SEPARATED, and that is the whole reason
this went unnoticed. I ran the obvious shell version of this audit two
commits ago - a loop over $(git ls-files) - and reported the repository
clean. It splits on whitespace, so it looked for a file called "SplitBit",
failed into /dev/null, and found nothing wrong with either manual because it
never opened them. Both have spaces in their names.

A check that cannot see the files with spaces in their names is worse than
no check at all, because it answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:16:54 -04:00
AnachronautandClaude Opus 5 b2945e41c4 docs.sh: say which heading went missing, rather than raising IndexError
The manual checks find what they examine by splitting the file on an exact
heading. Ten of the eleven anchors already say what they could not find -
"the Programming Manual has lost its Devices table" and so on. Two did not:
the worked hello world program and the hex dump beside it were reached with
pm.split(anchor)[1] and nothing else, so renaming either produced a Python
traceback and an IndexError.

A traceback is a worse answer than a stale manual. It says a check broke
without saying which heading moved, and it stops the rest of the run, so
whatever else was wrong stays unreported.

Both anchors are now tested before they are used, and both say which one is
missing and what that means. Verified by renaming each and reading the
message.

This is the first of four commits restructuring the Programming Manual, and
it comes first on purpose: the next three move headings around, and they
should be watched by checks that would notice.

IT ALSO CORRECTS THE PLAN. I had written that renaming a heading fails
silently, and set out to fix all eleven. Probing them one at a time showed
that was wrong - ten were already fine, and the job was one check rather
than the whole file. The claim was worth testing before acting on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 14:11:37 -04:00
AnachronautandClaude Opus 5 518be9cb17 Rewrite the README around what the system is now
It had become a geological record. The overview was the 2024 one - "load
and run binary programs created for SplitBit interactively from the command
line" - and the feature list had grown by accretion, so it opened with
Debug Mode, CLI Based and Modular Codebase and buried self-hosting at
bullet fourteen of seventeen. Three sections were called "Usage".

Now it opens with what the machine is and the one fact that makes it worth
looking at, with the transcript underneath. The features are grouped into
the machine and the software running on it, and the bullets that said
nothing are gone: "Binary File Support" and "CLI Based" describe every
emulator ever written.

The three tools have names in their headings rather than three identical
"Usage" ones, and their options are tables rather than bullet lists.

There is a map of the repository, which there was not before and which the
last two commits made worth having.

TWO THINGS IT WAS SAYING THAT WERE NO LONGER TRUE, and nothing checks the
README so nothing caught them:

  "it is still the only way to get a program onto a disk, since nothing
  running on the machine assembles anything yet" - SplitDisk has not been
  the only way for two days.

  "this is what a self-hosted assembler will stand on" - future tense about
  something that has since happened.

The Tests section also claimed five checking scripts and then listed
makedisks.sh among them, which builds fixtures rather than checking
anything. It is four checks and a runner.

Every command in it was run: assembling hello.asm and running the result,
and booting the disk to see Snake.sbx load. Three em dashes crept in and
were taken out - the whole tracked repository is plain ASCII again, which
is now verified rather than assumed.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 13:38:47 -04:00
AnachronautandClaude Opus 5 6dbb38b209 Stop committing assembled programs, nine of which had gone stale
Thirteen build artifacts were tracked: ten .bin files, a disk image, and the
text file that had been put on it. Every one is produced by 'make' in
Programs/ from a source file that is also tracked.

NINE OF THE TEN BINARIES DISAGREED WITH THEIR OWN SOURCE, including
Programs/CosmOS/Bin/cosmos.bin - the operating system. Only hello.bin still
matched, and that by accident.

That is worse than shipping no binary at all. A repository holding both a
source file and a stale build of it has two answers to what a program does,
and the wrong one is the one that runs. Somebody reads the source, runs the
binary, and sees behaviour that is not in the code with nothing to tell them
why.

Nothing referenced any of them - not the makefiles, not the tests, not the
manuals. Checked before removing, and checked after: the suite passes, and a
clean clone with all of them gone builds both tools and assembles every
program from source alone.

.gitignore now covers *.bin, *.sbx and *.img, unanchored, because a build
output may appear anywhere a source file lives.

Programs/CosmOS/Bin/ held cosmos.bin, disk.img and a 17 byte hello.txt that
existed only to be put on that image. It was a snapshot of a demo from
before Tests/makedisks.sh built its own fixtures, and it goes with them.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 12:03:31 -04:00
AnachronautandClaude Opus 5 20989c3439 M3: programs that bring their own vectors
> load Asm.sbx
    > run Keys.asm
    wrote Keys.sbx: program 558, data 85, labels 52
    > load Keys.sbx
    > run
    keys, by interrupt. q stops.
    ab
    the console has been handed back

The machine assembles a program carrying an interrupt handler, the loader
installs its vector, the console interrupts into it, and the shell takes the
vector back at exit. Byte for byte identical to the C assembler's, and
Tests/native.sh now checks a boot image and four loadable programs on every
run.

WHAT IT TOOK:

  A declaration and an implementation are the SAME ENTRY. services.asm says
  a service is called osPrintString and has number 16; cosmos.asm says
  osPrintString is handled by handlePrintString. The name is met twice and
  the second time fills in the handler, which is what lets one shared file
  serve both the caller and the implementer.

  So the first pass declares and the second implements. That is forced: a
  handler is an address, and no address is known until every label has been
  placed.

  Boot in a loadable program fills the entry field rather than being
  installed - vector zero is where the whole machine starts, and a program
  loaded into a running system has no business saying anything about that.
  A boot image is the one thing that does, so there it is installed like any
  other, behind a "VEC" marker in the SPBT file.

  Device is named by the port, and Device with the five reserved names are
  matched without regard to case, the way mnemonics are: they are part of
  the language rather than names the programmer chose. Devices have no names
  of their own, so they are given one nothing can type.

TWO BUGS, both of a kind worth naming.

The first: "is this a loadable program" was written out as an OR of the two
segment bases in seven places, and the sense wanted is the opposite in most
of them. One of the seven had it backwards and put a version ONE header on a
file carrying vectors, which a loader is right to refuse. It is one flag
now, settled once and tested the same way everywhere.

The second: finding the entry to write a handler into means calling vecFind,
which reads the entry's fields out - including the handler it does not have
yet. An address resolved into VecHandler before the find was overwritten
with zero by the find itself, and the file came out with a vector pointing
at address zero: a slot that looked installed and went nowhere. The
resolved address has a variable of its own now.

Keys.asm and console.asm go on the CosmOS disk, so the whole path can be
watched rather than only tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-21 11:50:52 -04:00
AnachronautandClaude Opus 5 04424a8917 Put the application hello.asm on the disk, not the bare metal one
Both are called hello.asm and they are different programs: Programs/hello.asm
is the 1978-style boot image that writes straight to port 0x00 and HALTs,
and CosmOS/Apps/hello.asm is the loadable one that ends with SWI osExit.
The disk had the first, which assembles to a .bin the shell cannot load, so
watching it work meant taking the file off the disk afterwards.

Now it has the second, and the whole thing is two commands:

    > load Asm.sbx
    > run hello.asm
    wrote hello.sbx: program 22, data 14, labels 3
    > load hello.sbx
    > run
    Hello, World!

It writes over the hello.sbx the host tool put there, so what runs is the
machine's own work. Byte for byte identical to the C assembler's, as ever.

strings.asm stays as it is, and now earns its place by being the odd one
out: no #Include and no #Base, so it assembles to a boot image rather than
a loadable program and the difference between the two is visible on one
disk.

Tests/native.sh still builds the bare metal hello.asm, and should: it is the
only thing that checks the SPBT path end to end, including running what came
out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-20 22:55:05 -04:00
AnachronautandClaude Opus 5 c5e4ec3455 M2: the native assembler builds applications
> load Asm.sbx
    > run Say.asm
    wrote Say.sbx: program 46, data 93, labels 7
    > load Say.sbx
    > run built by the machine itself
    it says: built by the machine itself

The machine assembles an application and then runs what it built. Say,
greet and Files all come out byte for byte identical to the C assembler's,
and Tests/native.sh checks all three on every run alongside the boot image
M1 already covered.

WHAT IT TOOK, and it was more than #Include and #Base:

  #Include   The reader is a stack of readers. The current file's whole
             state goes aside - buffer and all, 292 bytes - the new one
             opens, and the end of it pops the old one back. A file goes in
             once; including it twice does nothing, which is what lets two
             libraries depend on a third. The list is forgotten between the
             passes, because the second has to walk the same tree.
  #Base      Cursors start there, so labels hold the addresses the program
             will really have. A program that says where it goes gets the
             SBEX header and a .sbx name; one that says nothing gets SPBT
             and .bin. A program that bases one segment and leaves the
             other unbased with content in it is refused.
  #Reserve   Runs of zeroes, moved over in the first pass and written in
  #Align     the second. How many an #Align comes to depends on where the
             cursor has reached, which is why both passes keep a cursor.
  #Vectors   Names are read and numbered, pinned where the source pins
             them, so SWI osPrintString resolves. Every application needs
             this - a program that calls a service names a vector declared
             in a file it includes.

THE TWO PASSES ARE NOW ONE LOOP, walked twice, with Emitting the only
difference. They have to agree about the length of every token, and the way
they stop agreeing is by being two pieces of code that drifted apart -
which is the exact shape of the bug this assembler found in the C one.
Sharing the body means there is nothing to drift. What is left is checked
anyway: the second pass compares its own totals against the first's and
refuses to write the file if they differ.

THE BUG WORTH RECORDING. The tokenizer holds one character of lookahead,
and at an #Include that character belongs to the file being put aside. It
was carried across and handed back on the way out, which is wrong: a file
runs out in the middle of whatever the tokenizer happens to be doing, so
the character arrives in the middle of a word. `start:` came back as `s`
and then `tart:` - and the result assembled into a perfectly plausible
file. The fix is to undo the read instead, so the character is simply still
there when the file is opened again and the question of when to hand it
back never arises.

Three smaller ones, all old friends: three places took the CONTENTS of a
buffer where they wanted its ADDRESS; vecTakeAuto returned its answer in A,
which a RET puts back; and pass two re-declared every vector because only
the label table was being skipped on the second walk.

sameText moved down into numbers.asm from the label table - four parts want
it now, and a reader test that includes neither labels nor tokens has to
build on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-20 22:50:39 -04:00
AnachronautandClaude Opus 5 affe9d09ea Put the assembler and some source on the CosmOS disk
'make run-cosmos' now boots a machine with Asm.sbx on the disk and two
source files to point it at, so the thing can be watched working rather
than only tested:

    > load Asm.sbx
    > run hello.asm
    wrote hello.bin: program 17, data 14, labels 2
    > run strings.asm
    wrote strings.bin: program 62, data 88, labels 4

Both come out byte for byte identical to the C assembler's, and both run.
hello.asm and testPrograms/stringKeyword.asm are the two single-file
programs with no #Include, which is what the native assembler handles so
far; strings.asm is the second because it has a subroutine and a label
used before it is defined, so it exercises a forward reference that
hello.asm does not.

The assembler builds from its own directory rather than from Apps/,
because it is not one file. Its pieces are found beside it without being
told, since an include is looked for next to the file that asked for it
before anywhere else; only services.asm needs the include path.

ALSO A CORRECTION. Asm.asm's header, its usage message and the Assembler
Manual all said 'run Asm.sbx hello.asm'. That is the convention we talked
about wanting later, not the one this shell has: load and run are separate
commands, so the whole rest of the run line is the argument and that form
asks for a file called "Asm.sbx hello.asm". All three now say load first.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-20 22:13:15 -04:00
AnachronautandClaude Opus 5 a131a90c67 A string that spells a directive is a string, not a directive
The quotes are gone by the time a token is classified, so checkIfKeyword's
test of token[0] == '#' matched the STRING "#Program" sitting in a program's
Data Segment. It was read as the directive: the segment silently changed in
the middle of the data, the string's nine bytes were charged to the Program
cursor instead of the Data one, and every label defined after it came out
nine bytes wrong - in a file that still had a valid header, a plausible
length, and nothing to say about any of it. The only symptom was a program
that jumped into the middle of an instruction.

This is the FOURTH of the family. A string spelling a mnemonic assembled as
that instruction; a string beginning with a zero was rejected as a malformed
literal; a string in the Program Segment was discarded in silence. The
instruction check and the literal check both carry a "not a STRING" guard
already. This one did not, so it has one now, and it lives inside
checkIfKeyword rather than at the call site so it cannot be left off again.

Nothing had ever triggered it, because nothing had ever needed a directive's
name as data. An assembler written FOR this machine necessarily does: it has
to compare tokens against "#Program" and "#Data". It was found by building
one and watching it fault on its second instruction.

Test stringKeyword puts every directive name in a program's data and prints
a label defined after them. Verified that it bites: without the fix the
assembler refuses the file outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-20 22:12:47 -04:00
AnachronautandClaude Opus 5 ca243895ca Grow the CosmOS disk to 512K, because it has to hold its own source
cosmos.img was 256 blocks and two directory blocks: 64K, sixteen files.
Ample for a disk that held nothing but programs, and not enough for the
thing that comes next. The native assembler reads SOURCE from this disk,
and the CosmOS sources alone are 104,142 bytes against the 65,536 a 256
block disk holds - so the machine could not hold its own source code.

2048 blocks and four directory blocks: 512K and room for 32 files.

Nothing was recorded that had to change, which is worth saying rather
than assuming: dir lists names and does not report free space, so no
golden file names a block count.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:09:21 -04:00
433 changed files with 61134 additions and 2308 deletions
+34
View File
@@ -7,9 +7,43 @@
/Assembler /Assembler
/SplitBit /SplitBit
/SplitDisk /SplitDisk
/SplitLint
/Voyager
/SoundPatch
/TuneC
# A .score is written and a .tune is what TuneC makes of it, so a .tune is a build product
# the way a .sbx is. Not anchored: they turn up wherever somebody is composing.
*.tune
/CLAUDE.md /CLAUDE.md
/claudeResume.sh /claudeResume.sh
/codexResume.sh /codexResume.sh
# Python leaves these beside the test scripts. # Python leaves these beside the test scripts.
__pycache__/ __pycache__/
# Assembled programs. Every one of these is built by 'make' in Programs/ from a source
# file that is tracked, so committing them only creates a second answer to the question of
# what a program does - and the second answer goes stale silently. Nine of the ten that
# used to be here disagreed with their own source, including the operating system.
#
# Not anchored, because a .bin may be built anywhere a .asm lives.
*.bin
*.sbx
*.img
# Kate leaves these beside a file it has open.
.*.kate-swp
Source/Emulator/rom.c
# Other people's repositories, kept here to be read rather than to be built. soundThing is
# where the synthesizer comes from and Hatchet-GPU is where the font and the scroll register
# idea come from, and neither belongs to this one: they carry their own .git directories,
# their own build outputs and their own compiled binaries. What this repository takes from
# them gets vendored deliberately, with a note saying where it came from.
/External/
# The personal disk 'make run-voyager' puts in drive 1. Made on demand, never rebuilt, and
# not cleaned - it is the one place in here where something made ON the machine can live.
/Disks/
*.sym
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+8
View File
@@ -0,0 +1,8 @@
# Media
Screenshots and anything else that is looked at rather than read or built.
Nothing here is used by the build or the tests. The repository's README links to what is
in here, and `Tests/docs.sh` checks that every one of those links lands, so a file removed
from here without the link going too will fail the suite rather than becoming a broken
image on the front page.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
; bare.asm
; A machine with no operating system on it at all.
;
; THIS IS THE POINT OF LOADING AN ORDINARY IMAGE. The second stage does not know what
; CosmOS is; it knows what a boot image is, and this is one. So a program that wants the
; whole machine to itself - no shell, no filesystem, no services - is not a special case
; needing a special path. It is a file, written under CosmOS like any other, and started by
; naming it in /System/Boot/boot.cfg.
;
; It owns everything from address zero upward, and the only thing it can be sure of is the
; console, because that is all it asked for.
;
; Written by Anachronaut
#Program
start:
SETD.0 Said
sayLoop:
LDA.0
BRA sayDone
OUTA 0x00
INCD.0
BRI sayLoop
sayDone:
HALT
#Data
Said:
"bare metal: no system, just this
"
#Vectors
Boot start
+65
View File
@@ -0,0 +1,65 @@
; slotData.asm
; A boot slot payload that arranges its own Data Segment.
;
; Stage one places Program Memory and nothing else, because knowing where a payload's data
; ends and its code begins would mean knowing a format, and knowing formats is the thing
; ROM must do as little of as possible. So a payload that wants initialised data arranges
; it: a loadable image is written into the slot as code followed by data, which leaves the
; data image sitting in Program Memory just past the code, and the first thing the payload
; does is blit it down to where it was assembled for.
;
; This is how the real second stage will get the Data Segment that sbfs.asm needs.
;
; THE PADDING IS NOT DECORATION. The blit needs a length, the assembler will not work out
; a difference between two labels, so the segment is padded to a round number and that
; number is what gets copied. Reserve one byte too many and the last byte does not arrive:
; the first draft of this padded to 257 and copied 256.
;
; Written by Anachronaut
#Program
#Base 0xC000
start:
RSTA
OUTA 0xE0 ; SourceBank: Program Memory, where stage one put everything.
SETD.0 codeEnd
PSHD.0
POPA
POPB
OUTB 0xE1
OUTA 0xE2
INIA 0d1
OUTA 0xE3 ; DestBank: Data Memory.
SETD.0 Greeting
PSHD.0
POPA
POPB
OUTB 0xE4
OUTA 0xE5
INIA 0x01
OUTA 0xE6
RSTA
OUTA 0xE7 ; A round 256 bytes, which the segment is padded to.
INIA 0x01
OUTA 0xE8
SETD.0 Greeting
sayLoop:
LDA.0
BRA sayDone
OUTA 0x00
INCD.0
BRI sayLoop
sayDone:
HALT
codeEnd:
#Data
#Base 0x3000
Greeting:
"a payload with data of its own
"
Padding:
#Reserve 0d224
+33
View File
@@ -0,0 +1,33 @@
; slotTest.asm
; Something small to put in a boot slot, so that the chain can be proved end to end.
;
; NO DATA SEGMENT, and that is not tidiness - it is the shape of what stage one can do.
; Stage one reads raw blocks into Program Memory and jumps to the first byte. It places no
; data, because it has no way to know where a payload's data ends and its code begins
; without knowing a format, and knowing a format is the thing ROM must do as little of as
; possible. So a payload either carries no initialised data or arranges its own.
;
; Which is why this prints with INIA and OUTA rather than from a string. The real stage two
; needs a Data Segment, and how it gets one is an open question written up beside this.
;
; Written by Anachronaut
#Program
#Base 0xC000
here:
INIA 0x62 ; "b"
OUTA 0x00
INIA 0x6F ; "o"
OUTA 0x00
INIA 0x6F ; splitlint[redundant-assignment]: a second "o", spelled out like the rest
OUTA 0x00
INIA 0x74 ; "t"
OUTA 0x00
INIA 0x65 ; "e"
OUTA 0x00
INIA 0x64 ; "d"
OUTA 0x00
INIA 0x0A
OUTA 0x00
HALT
+263
View File
@@ -0,0 +1,263 @@
; stage1.asm
; The first thing the machine runs. Reads the boot area off the disk and jumps into it.
;
; THIS IS THE PART THAT ONE DAY CANNOT BE CHANGED. It is written to go in ROM, so
; everything it knows has to be a thing that is true forever: which port the disk is on,
; that a SplitBit disk begins with its own name, and where two numbers sit in the block
; that name is in. It does not know what a file is, what a directory is, or that SBFS has
; versions. All of that lives in the boot area, on the disk, where it can be replaced.
;
; The test to apply to any line added here is the only test that matters for ROM: am I
; certain this is right forever? A loader that could find /System/cosmos.bin by name would
; be friendlier and would freeze the filesystem format in silicon.
;
; It is an ordinary boot image for now, so it can be run and tested with everything that
; already exists. Nothing about it changes when it moves into ROM except who puts it in
; memory.
;
; Written by Anachronaut
#Program
; ---- What is known forever ----
;
; Disk: 0x20 block high, 0x21 block low, 0x22 command, 0x23 status
; Controller: 0xE0 source bank, 0xE1/0xE2 source, 0xE3 dest bank, 0xE4/0xE5 dest,
; 0xE6/0xE7 count, 0xE8 command
; Banks: 0 Program Memory, 1 Data Memory, 3 the disk's buffer once registered
start:
; The disk's buffer becomes bank 3. Memory a device brings is reachable only through the
; controller, so this is what makes the block readable at all.
INIA 0d3
OUTA 0xE3
INIA 0x20
OUTA 0xE2
INIA 0x03
OUTA 0xE8
; Block 0, the superblock.
RSTA
SETD.0 BlockHigh
STA.0
SETD.0 BlockLow
STA.0
RCAL readBlock
BNQ bootFailed
; "SBFS", or there is nothing here to boot from. Four bytes, compared where they landed.
SETD.2 ScratchAt
LDD.0.2
SETD.2 DiskMagic
INIA 0d4
SETD.1 Counter
STA.1
magicLoop:
LDA.0
LDB.2
XOR
BNQ bootFailed
INCD.0
INCD.2
SETD.1 Counter
LDA.1
DECA
STA.1
BNA magicLoop
; Two numbers, at fixed offsets in the block whose name has just been checked: how many
; blocks a boot slot holds, and which of the two slots to start from.
SETD.2 ScratchAt
LDD.0.2
DPUP.0 0d14
LDA.0
BNA bootFailed ; A high byte means a slot larger than this will ever read.
INCD.0
LDA.0
BRA bootFailed ; No boot area at all, so this disk cannot be started.
SETD.1 SlotBlocks
STA.1
SETD.2 ScratchAt
LDD.0.2
DPUP.0 0d16
LDA.0
; The first block of the live slot. Slot 0 begins at block 1 and slot 1 begins a whole
; slot later, so the only arithmetic is an addition and there is nothing to multiply.
BRA slotZero
SETD.0 SlotBlocks
LDA.0
INCA
BRI slotFound
slotZero:
INIA 0d1
slotFound:
SETD.0 BlockLow
STA.0
RSTA
SETD.0 BlockHigh
STA.0
; And where it goes. Stage two lives above everything the system will occupy, so that
; loading the system does not walk over the loader while it is still running.
SETD.0 StageHigh
INIA 0xC0
STA.0
readLoop:
RCAL readBlock
BNQ bootFailed
; The block, out of the disk's buffer and into Program Memory where it will be run.
INIA 0d3
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2
RSTA ; splitlint[redundant-assignment]: a bank number, not the address above
OUTA 0xE3 ; DestBank: Program Memory.
SETD.0 StageHigh
LDA.0
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0x01
OUTA 0xE6
RSTA
OUTA 0xE7 ; A whole block.
INIA 0x01
OUTA 0xE8
; On to the next block, and the next page of Program Memory to put it in.
SETD.0 StageHigh
LDA.0
INCA
STA.0
SETD.0 BlockLow
LDA.0
INCA
STA.0
BNA blockStepped
SETD.0 BlockHigh
LDA.0
INCA
STA.0
blockStepped:
SETD.0 SlotBlocks
LDA.0
DECA
STA.0
BNA readLoop
; Into it. Nothing is checked about what was read, because there is nothing here that
; could check it: what a valid stage two looks like is stage two's business, and a ROM
; that knew would be a ROM that could be wrong about it later.
SETD.0 StageStart
LDD.1.0
BRD.1
; ---- Reading the block the two block registers name ----
;
; RCAL rather than CALL because what it hands back is Q, and an ordinary call would put
; back the registers this leaves its answer in.
readBlock:
SETD.0 BlockHigh
LDA.0
OUTA 0x20
SETD.0 BlockLow
LDA.0
OUTA 0x21
INIA 0x01
OUTA 0x22 ; Read.
waitDisk:
INA 0x23
INIB 0x01
AND
BRQ readDone ; The busy bit is down, so there is nothing to wait for.
WAIT
BRI waitDisk
readDone:
; The error bit, which is the whole of what can go wrong down here.
INIB 0x02
AND
BNQ readBad
; And into Data Memory, where the CPU can look at it.
INIA 0d3
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2
INIA 0d1
OUTA 0xE3 ; DestBank: Data Memory.
INIA 0x80
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0x01
OUTA 0xE6
RSTA
OUTA 0xE7
INIA 0x01
OUTA 0xE8
RSTA
RSTB
CCF
ADD ; Q is zero: the block is in Scratch.
RRET
readBad:
RSTA
INIB 0d1
CCF
ADD
RRET
; ---- When there is nothing to start ----
;
; One character and a stop. A ROM has no room for an explanation and nowhere to put one:
; the console is the only thing it can be sure of, and even that only in the sense that
; writing to a port nobody is listening to costs nothing.
bootFailed:
INIA 0x3F ; "?"
OUTA 0x00
INIA 0x0A
OUTA 0x00
HALT
#Data
DiskMagic:
"SBFS"
BlockHigh:
0x00
BlockLow:
0x00
SlotBlocks:
0x00
Counter:
0x00
; Where stage two is being written, a page at a time, and where it begins. The high byte is
; stepped as the blocks go by; the low byte is always zero, because a block is a page.
StageHigh:
0xC0
StageStart:
0xC0 0x00
; Where the superblock, and then each block of the boot area, lands on its way past.
;
; AN ADDRESS RATHER THAN STORAGE. Reserving it, or aligning to it, would put thirty two
; kilobytes of zeroes into a file that is going to be a ROM - which is what the assembler's
; own scratch map exists to avoid, for the same reason. Two bytes here say where; nothing
; carries what.
ScratchAt:
0x80 0x00
#Vectors
Boot start
+698
View File
@@ -0,0 +1,698 @@
; stage2.asm
; The second stage. Finds the system on the disk and starts it.
;
; Stage one knows nothing about filesystems and never will, because it is going into a ROM
; and a ROM that knew SBFS would freeze the format in silicon. This is where that knowledge
; lives instead: on the disk, in a boot slot, replaceable by writing blocks.
;
; ---- What it loads ----
;
; An ordinary SplitBit boot image, the same SPBT file the emulator has always been handed.
; That is the whole trick and it was the user's: a second stage that loads the machine's
; NORMAL image format is not a boot-specific mechanism at all. Bare metal SplitBit stops
; being a special case - a program that wants no operating system is just an image, written
; under CosmOS like any other, and startable because it is a file.
;
; ---- How it gets its own data ----
;
; Stage one places Program Memory and nothing else. A loadable image is written into the
; slot as code followed by data, so this program's Data Segment is sitting in Program Memory
; just past its own code when it starts, and the first thing it does is blit it down. See
; slotData.asm, which proves the mechanism on its own.
;
; Written by Anachronaut
#Program
; Above everything the system will occupy, so that loading the system does not walk over
; the loader while it is still running.
#Base 0xC000
start:
; ---- The Data Segment, fetched from just past the code ----
;
; A ROUND FOUR KILOBYTES rather than the exact size. The blit needs a length, the
; assembler will not work out the difference between two labels, and a hand kept number
; is a number that goes wrong quietly - slotData.asm padded to 257 and copied 256, and
; the byte that never arrived happened to be padding. Copying more than there is costs
; nothing: the source runs into slot padding, and the destination is nobody's memory.
RSTA
OUTA 0xE0 ; SourceBank: Program Memory, where stage one put everything.
SETD.0 codeEnd
PSHD.0
POPA
POPB
OUTB 0xE1
OUTA 0xE2
INIA 0d1
OUTA 0xE3 ; DestBank: Data Memory.
SETD.0 StageDataBase
PSHD.0
POPA
POPB
OUTB 0xE4
OUTA 0xE5
INIA 0x10
OUTA 0xE6
RSTA
OUTA 0xE7 ; Four kilobytes.
INIA 0x01
OUTA 0xE8
; ---- Now there is data, so there can be words ----
SETD.0 StageName
RCAL say
CALL sbfsMount
BNQ noFilesystem
; ---- What to start ----
;
; Read before the image is, because the configuration is staged where the image will go:
; there is one large free area down here and no reason to have two.
; ---- Something to start just this once ----
;
; A file naming an image to run instead of the configured system, and then to forget
; about. It is the same format as boot.cfg and is read the same way, because a second
; format for one setting would be a second format.
;
; CONSUMED BY BEING READ, not by working. It is deleted before the jump, which is the
; only moment there is: after the jump this program does not exist. That also means a
; one shot that hangs cannot hang twice - the request is already gone, and the mark on
; the disk brings the ordinary system back.
SETD.0 OncePath
SETD.2 StageAt
LDD.1.2
INIA 0d4
CALL cfgLoad
SETD.0 KeySystem
CALL cfgGet
BNQ noOnce
SETD.1 BootName
SETD.2 CfgValue
LDD.0.2
RCAL copyString
; Gone before it is used, so that whatever happens next happens only once.
SETD.0 OncePath
CALL sbfsDelete
SETD.0 OnceText
RCAL say
SETD.0 BootName
RCAL say
RCAL newLine
; ---- AND THE MARK IS NOT TOUCHED ----
;
; A one shot is already self limiting: the request was deleted a moment ago, so whatever
; happens now, the next start reads boot.cfg like any other. Marking it as well would
; report every successful bare metal boot as a start that never arrived - which is what
; the first version did, because a program that owns the whole machine has no filesystem
; to clear a mark with and is not doing anything wrong by not having one.
SETD.0 BootName
RCAL tryImage
; It did not start, and there is nothing to fall back to that was asked for. Whatever
; boot.cfg says is the thing to try, so carry on into it.
SETD.0 OnceFailedText
RCAL say
noOnce:
SETD.0 ConfigPath
SETD.2 StageAt
LDD.1.2
INIA 0d4
CALL cfgLoad
; Said before anything is started, so that a setting somebody meant which did not happen
; is on the screen above whatever happened instead.
SETD.0 KnownKeys
CALL cfgCheck
SETD.0 KeySystem
CALL cfgGet
SETD.1 BootName
BNQ systemDefault
SETD.2 CfgValue
LDD.0.2
RCAL copyString
BRI systemChosen
systemDefault:
SETD.0 SystemName
RCAL copyString
systemChosen:
; And what to fall back to, kept now because the configuration is about to be written
; over by the image itself.
RSTA
SETD.0 HaveFallback
STA.0
SETD.0 KeyFallback
CALL cfgGet
BNQ noFallbackSet
SETD.1 FallbackName
SETD.2 CfgValue
LDD.0.2
RCAL copyString
INIA 0x01
SETD.0 HaveFallback
STA.0
noFallbackSet:
; ---- How the last start went ----
;
; Settled means the last one arrived, so try what the configuration asks for. Trying
; means the loader handed over last time and nothing came back to say it got there, so
; the thing named is what broke the machine and the fallback is the way out. Fell back
; means that already happened and nobody has settled it since, so do the same again
; rather than retrying a system that is known not to start.
CALL sbfsBootState
SETD.0 SbfsStateWas
LDA.0
BRA stateSettled
INIB 0d1
XOR
BRQ stateFailedLast
SETD.0 StillBackText
RCAL say
BRI useFallback
stateFailedLast:
SETD.0 DidNotArriveText
RCAL say
INIA 0d2
CALL sbfsSetBootState
BRI useFallback
stateSettled:
; The mark goes down BEFORE the jump, because after it there is nothing here to do it.
; What clears it is the system reaching its prompt, which is not a claim that the system
; works - it is the moment a person can tell it otherwise.
INIA 0d1
CALL sbfsSetBootState
SETD.0 BootName
RCAL tryImage
; It did not start. Whatever went wrong has already said so, and if there is something
; else to try then trying it is the whole reason for having said it rather than stopping.
SETD.0 FallbackText
RCAL say
useFallback:
SETD.0 HaveFallback
LDA.0
BRA noFallbackTryAgain
SETD.0 FallbackName
RCAL tryImage
BRI noSystem
noFallbackTryAgain:
; NOTHING TO FALL BACK TO, so try what was asked for anyway rather than stopping. With no
; second name the mark is the only thing standing between the machine and its own
; configuration, and refusing on the strength of it would turn "the last start failed"
; into "no start is permitted", which is worse than the problem it was added to solve.
;
; A failure that was passing recovers here. One that is not leaves the machine exactly
; where it would have been without any of this, which is the most that can be promised
; when there is only one thing to start.
SETD.0 NoFallbackText
RCAL say
SETD.0 BootName
RCAL tryImage
BRI noSystem
; ---- Starting one particular image ----
;
; DP0 names it. Returns only if it could not be started, having said why; everything that
; works ends in the jump at the bottom and never comes back.
tryImage:
SETD.1 TryName
STD.0.1
CALL sbfsFind
BNQ tryMissing
; ---- The image, read whole into somewhere nothing else is using ----
SETD.2 StageAt
LDD.1.2
CALL sbfsRead
BNQ unreadable
; "SPBT", or this is a file rather than something to start.
SETD.2 StageAt
LDD.0.2
SETD.2 BootMagic
INIA 0d4
RCAL matchTag
BNQ notAnImage
; The format version, and then four bytes of feature flags. A flag set is an image
; asking for something this machine may not have, and the honest answer to a request
; that cannot be understood is to refuse rather than to run it anyway.
LDA.0
INIB 0d1
XOR
BNQ wrongVersion
INCD.0
INIA 0d4
RCAL allZero
BNQ wantsMore
; "PRG", its length, and then the code itself straight into Program Memory.
SETD.2 ProgTag
INIA 0d3
RCAL matchTag
BNQ notAnImage
RCAL takeCount
RSTA
RCAL placeSegment
; "DAT", the same again into Data Memory.
SETD.2 DataTag
INIA 0d3
RCAL matchTag
BNQ notAnImage
RCAL takeCount
INIA 0d1
RCAL placeSegment
; ---- "VEC", which an image written before vectors existed simply does not have ----
;
; So running out of file here is the ordinary case rather than a fault: what follows is
; the end of a perfectly good image, and the entry point is then the address zero.
SETD.2 VecTag
INIA 0d3
RCAL matchTag
BNQ noVectors
RCAL takeCount
vectorLoop:
SETD.2 CountHigh
LDA.2
INCD.2
LDB.2
OR
BRQ vectorsDone
; Four bytes: where in Program Memory the vector sits, then what to put there. The
; controller writes it, because nothing else can write Program Memory.
LDA.0
SETD.2 VecWhere
STA.2
INCD.0
LDA.0
INCD.2
STA.2
INCD.0
RSTA
OUTA 0xE3 ; DestBank: Program Memory.
SETD.2 VecWhere
LDA.2
OUTA 0xE4
INCD.2
LDA.2
OUTA 0xE5
LDA.0
OUTA 0xE9 ; A byte straight in, rather than a blit of one.
INCD.0
LDA.0
OUTA 0xE9
INCD.0
; THE BOOT VECTOR IS THE ONE WORTH KEEPING. Program Memory cannot be read back, so the
; address to start at has to be noticed on its way past rather than looked up after.
SETD.2 VecWhere
LDA.2
INIB 0xFC
XOR
BNQ vectorStepped
INCD.2
LDA.2
BNA vectorStepped
SETD.2 EntryHigh
DECD.0
DECD.0
LDA.0
STA.2
INCD.0
LDA.0
INCD.2
STA.2
INCD.0
vectorStepped:
SETD.2 CountLow
LDA.2
INIB 0d4
CCF
SUB
MVQA
STA.2
BNC vectorLoop
SETD.2 CountHigh
LDA.2
DECA
STA.2
BRI vectorLoop
noVectors:
vectorsDone:
; And into it. Everything above has been arranging memory; this is the only instruction
; that hands the machine over.
SETD.0 EntryHigh
LDD.1.0
BRD.1
unreadable:
SETD.0 UnreadableText
RCAL say
RCAL sayName
RRET
notAnImage:
SETD.0 NotImageText
RCAL say
RCAL sayName
RRET
wrongVersion:
SETD.0 VersionText
RCAL say
RCAL sayName
RRET
wantsMore:
SETD.0 FeatureText
RCAL say
RCAL sayName
RRET
noFilesystem:
SETD.0 NoDiskText
RCAL say
HALT
tryMissing:
SETD.0 NoSystemText
RCAL say
RCAL sayName
RRET
; Whichever image was being tried, said after the reason it did not work.
sayName:
SETD.0 TryName
LDD.0.0
RCAL say
RCAL newLine
RRET
noSystem:
SETD.0 NothingText
RCAL say
HALT
; DP0 names a string and DP1 where to put it, terminator and all.
copyString:
LDA.0
STA.1
BRA copyDone
INCD.0
INCD.1
BRI copyString
copyDone:
RRET
; ---- Comparing a marker and stepping past it ----
;
; DP0 is in the image, DP2 names what it should say, A is how many bytes. DP0 is left past
; them either way, which is what lets a caller try one marker and carry on.
matchTag:
SETD.1 TagLeft
STA.1
tagLoop:
SETD.1 TagLeft
LDA.1
BRA tagSame
DECA
STA.1
LDA.0
LDB.2
XOR
BNQ tagDiffers
INCD.0
INCD.2
BRI tagLoop
tagSame:
RSTA
RSTB
CCF
ADD
RRET
tagDiffers:
; Past the rest of it regardless, so that what DP0 points at does not depend on which
; byte happened to differ.
SETD.1 TagLeft
LDA.1
BRA tagDiffersDone
DECA
STA.1
INCD.0
BRI tagDiffers
tagDiffersDone:
RSTA
INIB 0d1
CCF
ADD
RRET
; A bytes at DP0, all of which have to be zero. Used for the feature flags, where anything
; set is the image asking for something this machine may not be able to give it.
allZero:
SETD.1 TagLeft
STA.1
zeroLoop:
SETD.1 TagLeft
LDA.1
BRA zeroAllClear
DECA
STA.1
LDA.0
INCD.0
BNA zeroNotClear
BRI zeroLoop
zeroAllClear:
RSTA
RSTB
CCF
ADD
RRET
zeroNotClear:
RSTA
INIB 0d1
CCF
ADD
RRET
; Two bytes at DP0, most significant first, into the count that placeSegment and the vector
; loop both work through. DP0 is left on the byte after them.
takeCount:
LDA.0
SETD.1 CountHigh
STA.1
INCD.0
LDA.0
SETD.1 CountLow
STA.1
INCD.0
RRET
; ---- A segment, out of the staged image and into the memory it belongs in ----
;
; A is the bank: 0 for Program Memory, 1 for Data. Both segments go to address zero, which
; is what a boot image means - it is the format for something that owns the machine.
;
; DP0 is left past the segment, ready for whatever marker comes next.
placeSegment:
SETD.1 WantBank
STA.1
INIA 0d1
OUTA 0xE0 ; SourceBank: Data Memory, where the image was staged.
PSHD.0
POPA
POPB
OUTB 0xE1
OUTA 0xE2
SETD.1 WantBank
LDA.1
OUTA 0xE3
RSTA
OUTA 0xE4
OUTA 0xE5 ; To the bottom of it.
SETD.1 CountHigh
LDA.1
OUTA 0xE6
INCD.1
LDA.1
OUTA 0xE7
INIA 0x01
OUTA 0xE8
; And past it. DPUW steps a pointer by A and B together, which is exactly the shape the
; two halves of a length are already in.
SETD.1 CountHigh
LDA.1
INCD.1
LDB.1
DPUW.0
RRET
; ---- Saying something ----
;
; Its own rather than console.asm's, because everything this includes has to fit in a boot
; slot and the console library brings a great deal this will never use. DP0 names a string.
say:
LDA.0
BRA sayDone
OUTA 0x00
INCD.0
BRI say
sayDone:
RRET
newLine:
INIA 0x0A
OUTA 0x00
RRET
#Data
; Out of the way of everything: the system's data goes to 0x0000, and whatever image is
; being loaded is staged below this.
#Base 0xE000
; The first thing in the segment, so that its address is where the blit above puts the
; whole of it.
StageDataBase:
StageName:
"stage two
"
FoundText:
"found "
NoDiskText:
"no filesystem
"
NoSystemText:
"no "
; What to start. A name for now; the plan is for this to be read out of a file so that any
; number of systems can sit on one disk and the choice is an ordinary safe write.
; Where the configuration lives, and what to start when it does not say.
ConfigPath:
"/System/Boot/boot.cfg"
OncePath:
"/System/Boot/once.cfg"
SystemName:
"/System/Boot/cosmos.bin"
KeySystem:
"system"
KeyFallback:
"fallback"
; The keys this knows. Anything else in the file is a setting nothing asked for, and
; cfgCheck says so rather than letting it look as though it worked.
KnownKeys:
"system"
"fallback"
""
FallbackText:
"trying the fallback
"
DidNotArriveText:
"the last start did not arrive
"
StillBackText:
"still on the fallback: settle it to try again
"
NoFallbackText:
"no fallback, so trying it again
"
NothingText:
"nothing to start
"
OnceText:
"just this once: "
OnceFailedText:
"it did not start, so carrying on
"
BootName:
#Reserve 0d128
FallbackName:
#Reserve 0d128
HaveFallback:
0x00
TryName:
0x00 0x00
UnreadableText:
"could not read it
"
NotImageText:
"not a boot image
"
VersionText:
"an image this cannot start
"
FeatureText:
"the image wants more machine
"
BootMagic:
"SPBT"
ProgTag:
"PRG"
DataTag:
"DAT"
VecTag:
"VEC"
TagLeft:
0x00
WantBank:
0x00
CountHigh:
0x00
CountLow:
0x00
VecWhere:
0x00 0x00
; Where to start once memory is arranged. Zero unless the image says otherwise, which is
; what a boot image with no vector table means and what every program written before the
; table existed relies on.
EntryHigh:
0x00
EntryLow:
0x00
; Where the image is put while it is being taken apart. Below this program's own data and
; above everything the system will occupy. An address rather than storage: reserving it
; would put its zeroes in the boot slot.
StageAt:
0x20 0x00
#Include sbfs.asm
#Include text.asm
#Include config.asm
; ---- The end of everything ----
;
; Back to the Program Segment so that this label lands past the included code as well as
; past the code above. It is where the Data Segment's image begins once stage one has put
; the whole slot into Program Memory.
#Program
codeEnd:
+35
View File
@@ -0,0 +1,35 @@
; wedged.asm
; A system that starts and never reaches a prompt.
;
; Not a broken program so much as a stand in for one: what matters is that it is handed the
; machine and never clears the mark the loader put on the disk, which is what every real
; way of failing before the shell has in common.
;
; Without that mark, pointing /System/Boot/boot.cfg at something like this would be a
; machine that could not be told anything ever again - the shell is the only way to change
; the file, and the file is what stops the shell from starting.
;
; Written by Anachronaut
#Program
start:
SETD.0 Dying
sayLoop:
LDA.0
BRA gone
OUTA 0x00
INCD.0
BRI sayLoop
gone:
HALT
#Data
Dying:
"a system that never reaches a prompt
"
#Vectors
Boot start
+84
View File
@@ -0,0 +1,84 @@
; Stopping a program to look at it.
;
; SWI osBreak shows every register as this program had them, waits for a key, and carries
; on. It is two bytes and it fires every time it is reached.
;
; WHY IT IS AN INSTRUCTION RATHER THAN SOMETHING SET FROM OUTSIDE. A breakpoint that was
; poked into a running program would have to overwrite an instruction, and then putting that
; instruction back in order to continue is the same act as disarming the breakpoint. Firing
; a second time would mean stepping over the restored instruction and putting the breakpoint
; back behind it, and this machine cannot step one instruction. Nothing is overwritten here,
; so there is nothing to restore and nothing to re-arm.
;
; The price is that it is part of the program. A build with breakpoints in it has different
; addresses from a build without, which is the same bargain every machine makes that has a
; break instruction.
;
; Correct output is two stops. A and B differ between them, the addresses differ, and the
; Stack Pointer differs too, because the second one is inside a subroutine and a call has
; put ten bytes down by then.
;
; EVERY POINTER IS SET BEFORE EACH STOP, and the three this program owns are rotated
; between the two so that all of them visibly change.
;
; This program used to leave DP1 and DP2 alone, and what a stop then showed for them was
; whatever the shell happened to have left there. That is a real thing about the machine -
; a program is handed the pointers as it finds them - but it is not this program's to
; demonstrate, and it made what this program prints depend on where CosmOS's code happens
; to sit. The recorded output had to be taken again four times in one day's work, every
; time for a value nothing should ever depend on.
;
; A demonstration of what the registers were should show registers somebody chose. Then
; every line of it is being asserted rather than merely observed.
;
; DP3 is deliberately still the system's. It is where the program was entered, which is the
; one thing here worth seeing that this program did not choose, and it is steady because it
; is this program's own base.
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Banner
SWI osPrintString
INIA 0d17
INIB 0d34
SETD.0 Marker
SETD.1 Banner
SETD.2 DoneText
SWI osBreak
; The second stop is inside a subroutine, so that the Stack Pointer is visibly not where
; it was: a call puts ten bytes down before this one gets there.
CALL deeper
SETD.0 DoneText
SWI osPrintString
RSTA
SWI osExit
deeper:
INIA 0d68
INIB 0d85
SETD.0 Banner
SETD.1 DoneText
SETD.2 Marker
SWI osBreak
RET
#Data
#Base 0x3000
Banner:
"two stops, and what the registers were at each
"
Marker:
"marker"
DoneText:
"carried on to the end
"
+107
View File
@@ -0,0 +1,107 @@
; Tries to commit a file bigger than the room it reserved.
;
; osFileStart sets aside an extent and osFileWrite refuses a block index outside it, so the
; obvious way to reach a neighbouring file - writing off the end - is already barred. This
; is the other way to the same place: reserve one block, write the one block, and then tell
; osFileDone the file came to two.
;
; NOTHING WOULD SAY SO IF THAT WERE ALLOWED. A directory entry is the only record of what a
; file owns, so an entry claiming a block it was never given simply owns it, and whatever
; owned it before owns it too. Both files then look perfectly well formed.
;
; Correct behaviour is a refusal, and the file left as it was. The reservation is one block
; and a tail of ten, so:
;
; two blocks and no tail more than was reserved refused
; one block and a tail exactly what was reserved allowed
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; One block, and ten bytes after it.
SETD.0 Name
SETD.3 0x00 0x01
INIA 0d10
SWI osFileStart
BNQ noStart
SETD.1 Block
RSTA
RSTB
SWI osFileWrite
BNQ noWrite
SETD.1 Block
RSTA
INIB 0d1
SWI osFileWrite
BNQ noWrite
; Two whole blocks, which is more than one block and a tail.
SETD.3 0x00 0x02
RSTA
SWI osFileDone
BNQ refused
SETD.0 Allowed
SWI osPrintString
RSTA
SWI osExit
refused:
SETD.0 Refused
SWI osPrintString
; And the honest size, which is what was reserved.
SETD.3 0x00 0x01
INIA 0d10
SWI osFileDone
BNQ noHonest
SETD.0 Honest
SWI osPrintString
RSTA
SWI osExit
noHonest:
SETD.0 NoHonest
SWI osPrintString
INIA 0d1
SWI osExit
noStart:
SETD.0 NoStart
SWI osPrintString
INIA 0d1
SWI osExit
noWrite:
SETD.0 NoWrite
SWI osPrintString
INIA 0d1
SWI osExit
#Data
#Base 0x3000
Name:
"claim.dat"
Block:
#Reserve 0d256
Allowed:
"claiming more than was reserved was ALLOWED
"
Refused:
"claiming more than was reserved was refused
"
Honest:
"and the size it really came to was taken
"
NoHonest:
"the honest size was refused too
"
NoStart:
"no start
"
NoWrite:
"no write
"
+284
View File
@@ -0,0 +1,284 @@
; Compare two files without asking either one to fit in Data Memory.
;
; Each occupied block is read into its own 256-byte buffer. The byte count returned for
; the block is compared before its contents, and only those bytes are examined: bytes
; after the end of a short final block belong to neither file and are allowed to differ.
;
; Written by ChatGPT for Anachronaut's SplitBit
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Arguments
INIB 0xFF
SWI osArgument
SETD.0 Arguments
LDA.0
BRA usage
CALL textSplit
SETD.0 TextRest
LDD.0.0
LDA.0
BRA usage
SETD.0 Arguments
SWI osFileInfo
BNQ firstFailed
SETD.0 FirstBlocks
STD.3.0
SETD.0 TextRest
LDD.0.0
SWI osFileInfo
BNQ secondFailed
SETD.0 SecondBlocks
STD.3.0
; A different number of occupied blocks is immediately a different file.
SETD.0 FirstBlocks
LDA.0
SETD.1 SecondBlocks
LDB.1
CCF
SUB
BNQ different
INCD.0
INCD.1
LDA.0
LDB.1
CCF
SUB
BNQ different
SETD.0 FirstBlocks
SETD.1 RemainingBlocks
CALL copyWord
RSTA
SETD.0 Index
STA.0
INCD.0
STA.0
nextBlock:
SETD.0 RemainingBlocks
LDA.0
INCD.0
LDB.0
OR
BRQ alike
SETD.0 Arguments
SETD.1 FirstBlock
CALL readAtIndex
BNQ firstReadFailed
SETD.0 FirstCount
STD.3.0
SETD.0 TextRest
LDD.0.0
SETD.1 SecondBlock
CALL readAtIndex
BNQ secondReadFailed
SETD.0 SecondCount
STD.3.0
; Equal occupied-block counts do not imply equal tails, so compare the valid byte count
; returned for this block as well.
SETD.0 FirstCount
LDA.0
SETD.1 SecondCount
LDB.1
CCF
SUB
BNQ different
INCD.0
INCD.1
LDA.0
LDB.1
CCF
SUB
BNQ different
SETD.0 FirstCount
SETD.1 BytesLeft
CALL copyWord
SETD.0 FirstBlock
SETD.1 SecondBlock
compareByte:
SETD.2 BytesLeft
LDA.2
INCD.2
LDB.2
OR
BRQ blockSame
LDA.0
LDB.1
CCF
SUB
BNQ different
INCD.0
INCD.1
SETD.2 BytesLeft
CALL takeByte
BRI compareByte
blockSame:
CALL stepIndex
CALL takeBlock
BRI nextBlock
; DP0 is the path and DP1 the destination buffer. Q and DP3 are the service answers.
readAtIndex:
SETD.2 Index
LDA.2
INCD.2
LDB.2
SWI osFileBlock
RET
; DP0 names the source word and DP1 the destination word.
copyWord:
LDA.0
STA.1
INCD.0
INCD.1
LDA.0
STA.1
RET
stepIndex:
SETD.0 Index
INCD.0
LDA.0
INCA
STA.0
BNC stepDone
DECD.0
LDA.0
INCA
STA.0
stepDone:
RET
takeBlock:
SETD.0 RemainingBlocks
INCD.0
LDA.0
BRA blockBorrow
DECA
STA.0
RET
blockBorrow:
INIA 0xFF
STA.0
DECD.0
LDA.0
DECA
STA.0
RET
; Decrement the big-endian sixteen-bit BytesLeft. A full block arrives as 0x0100, so an
; eight-bit counter would compare none of it and call many different files equal.
takeByte:
INCD.2
LDA.2
BRA byteBorrow
DECA
STA.2
RET
byteBorrow:
INIA 0xFF
STA.2
DECD.2
LDA.2
DECA
STA.2
RET
alike:
SETD.0 SameText
SWI osPrintString
RSTA
SWI osExit
different:
SETD.0 DifferentText
SWI osPrintString
INIA 0d1
SWI osExit
usage:
SETD.0 Usage
SWI osPrintString
INIA 0d2
SWI osExit
firstFailed:
SETD.0 FirstError
SWI osPrintString
INIA 0d2
SWI osExit
secondFailed:
SETD.0 SecondError
SWI osPrintString
INIA 0d2
SWI osExit
firstReadFailed:
SETD.0 FirstReadError
SWI osPrintString
INIA 0d2
SWI osExit
secondReadFailed:
SETD.0 SecondReadError
SWI osPrintString
INIA 0d2
SWI osExit
#Data
#Base 0x3000
Arguments:
#Reserve 0d256
FirstBlocks:
0x00 0x00
SecondBlocks:
0x00 0x00
RemainingBlocks:
0x00 0x00
Index:
0x00 0x00
FirstCount:
0x00 0x00
SecondCount:
0x00 0x00
BytesLeft:
0x00 0x00
FirstBlock:
#Reserve 0d256
SecondBlock:
#Reserve 0d256
Usage:
"compare: give me two files
"
FirstError:
"compare: cannot find the first file
"
SecondError:
"compare: cannot find the second file
"
FirstReadError:
"compare: cannot read the first file
"
SecondReadError:
"compare: cannot read the second file
"
SameText:
"the same
"
DifferentText:
"different
"
#Include text.asm
+276
View File
@@ -0,0 +1,276 @@
; Copy one file to another without asking either file to fit in Data Memory.
;
; The two paths are the two words in the argument. Filesystem names can contain spaces,
; but the CosmOS command line has no quoting yet, so this deliberately has the same rule
; as the shell: a space separates things.
;
; osFileInfo reports how many disk blocks the source occupies, while osFileStart wants the
; shape stored in an SBFS entry: whole blocks and a possible tail. Reading the last block
; first recovers that distinction. The ordinary copy then walks forward one block at a
; time, through one 256-byte buffer.
;
; Written by ChatGPT for Anachronaut's SplitBit
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Arguments
INIB 0xFF
SWI osArgument
SETD.0 Arguments
LDA.0
BRA usage
CALL textSplit
SETD.0 TextRest
LDD.0.0
LDA.0
BRA usage
; How many occupied blocks the source has. Keep it as both the loop bound and the
; distinction between an empty file and one whose last block must be inspected.
SETD.0 Arguments
SWI osFileInfo
BNQ sourceFailed
SETD.0 SourceBlocks
STD.3.0
PSHD.3
POPB
POPA
OR
BRQ emptySource
; Read the final occupied block. DP3 says how many bytes of it belong to the file:
; 0x0100 for a whole block, or 0x0001 through 0x00FF for a tail.
SETD.0 SourceBlocks
LDD.3.0
DECD.3
SETD.0 Index
STD.3.0
SETD.0 Arguments
SETD.1 Block
SETD.2 Index
LDA.2
INCD.2
LDB.2
SWI osFileBlock
BNQ readFailed
SETD.0 LastCount
STD.3.0
; A full final block means every occupied block is whole. A short final block means
; there is one fewer whole block and its low-byte count is the tail.
SETD.0 LastCount
LDA.0
BNA wholeEnding
SETD.0 Index
SETD.1 OutputWhole
CALL copyWord
SETD.0 LastCount
INCD.0
LDA.0
SETD.1 OutputTail
STA.1
BRI sizeKnown
wholeEnding:
SETD.0 SourceBlocks
SETD.1 OutputWhole
CALL copyWord
RSTA
SETD.0 OutputTail
STA.0
sizeKnown:
BRI startOutput
emptySource:
RSTA
SETD.0 OutputWhole
STA.0
INCD.0
STA.0
SETD.0 OutputTail
STA.0
startOutput:
SETD.0 TextRest
LDD.0.0
SETD.2 OutputWhole
LDD.3.2
SETD.2 OutputTail
LDA.2
SWI osFileStart
BNQ startFailed
; Empty files have no blocks to transfer, but still need committing so that an existing
; destination becomes an empty file.
SETD.0 SourceBlocks
SETD.1 RemainingBlocks
CALL copyWord
RSTA
SETD.0 Index
STA.0
INCD.0
STA.0
copyNext:
SETD.0 RemainingBlocks
LDA.0
INCD.0
LDB.0
OR
BRQ copyDone
SETD.0 Arguments
SETD.1 Block
SETD.2 Index
LDA.2
INCD.2
LDB.2
SWI osFileBlock
BNQ readFailed
SETD.1 Block
SETD.2 Index
LDA.2
INCD.2
LDB.2
SWI osFileWrite
BNQ writeFailed
CALL stepIndex
CALL takeBlock
BRI copyNext
copyDone:
SETD.2 OutputWhole
LDD.3.2
SETD.2 OutputTail
LDA.2
SWI osFileDone
BNQ doneFailed
SETD.0 Copied
SWI osPrintString
RSTA
SWI osExit
; DP0 names the source word and DP1 the destination word.
copyWord:
LDA.0
STA.1
INCD.0
INCD.1
LDA.0
STA.1
RET
; Increment the big-endian sixteen-bit Index.
stepIndex:
SETD.0 Index
INCD.0
LDA.0
INCA
STA.0
BNC stepDone
DECD.0
LDA.0
INCA
STA.0
stepDone:
RET
; Decrement the big-endian sixteen-bit RemainingBlocks.
takeBlock:
SETD.0 RemainingBlocks
INCD.0
LDA.0
BRA takeBorrow
DECA
STA.0
RET
takeBorrow:
INIA 0xFF
STA.0
DECD.0
LDA.0
DECA
STA.0
RET
usage:
SETD.0 Usage
SWI osPrintString
INIA 0d2
SWI osExit
sourceFailed:
SETD.0 SourceError
SWI osPrintString
INIA 0d1
SWI osExit
readFailed:
SETD.0 ReadError
SWI osPrintString
INIA 0d1
SWI osExit
startFailed:
SETD.0 StartError
SWI osPrintString
INIA 0d1
SWI osExit
writeFailed:
SETD.0 WriteError
SWI osPrintString
INIA 0d1
SWI osExit
doneFailed:
SETD.0 DoneError
SWI osPrintString
INIA 0d1
SWI osExit
#Data
#Base 0x3000
Arguments:
#Reserve 0d256
SourceBlocks:
0x00 0x00
RemainingBlocks:
0x00 0x00
Index:
0x00 0x00
LastCount:
0x00 0x00
OutputWhole:
0x00 0x00
OutputTail:
0x00
Block:
#Reserve 0d256
Usage:
"copy: give me a source and destination
"
SourceError:
"copy: cannot find the source
"
ReadError:
"copy: cannot read the source
"
StartError:
"copy: cannot create the destination
"
WriteError:
"copy: cannot write the destination
"
DoneError:
"copy: cannot finish the destination
"
Copied:
"copied
"
#Include text.asm
+145
View File
@@ -0,0 +1,145 @@
; Breaks on purpose, in whichever of the four ways it is asked for.
;
; Every one of these used to stop the machine and print a line to a standard error that
; nobody behind a window is looking at, so the machine appeared to hang. The system catches
; all of them now and says what happened and where, and this is what says so - and what a
; person can run when they want to see the fault screen without having written a bug first.
;
; Crash opcode a byte in the middle of the code that does not decode
; Crash service a SWI naming a service the system does not implement
; Crash bank a transfer out of a bank that has nothing registered in it
; Crash device asking the console to interrupt, with no handler installed
; Crash blind the same bad byte, but from a screen with nowhere to print at all
;
; The last one is the odd one: it is not something this program does wrong, it is something
; it fails to have done. The interrupt arrives from outside once the console has anything to
; say, and with no input it is the END of input that arrives.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Argument
INIB 0d15
SWI osArgument
SETD.0 Argument
SETD.1 WordOpcode
CALL textSame
BRQ crashOpcode
SETD.0 Argument
SETD.1 WordService
CALL textSame
BRQ crashService
SETD.0 Argument
SETD.1 WordBank
CALL textSame
BRQ crashBank
SETD.0 Argument
SETD.1 WordDevice
CALL textSame
BRQ crashDevice
SETD.0 Argument
SETD.1 WordBlind
CALL textSame
BRQ crashBlind
SETD.0 Usage
SWI osPrintString
INIA 0x0A
OUTA 0x00
INIA 0x01
SWI osExit
; ---- The case the fault screen exists for ----
;
; Bitmap mode has no text rows, so the console draws NOTHING there: a program that faults
; here leaves the system with a message to print and nowhere to print it. Putting the screen
; back into a mode that has characters in it is the difference between a diagnosis and a
; machine that appears to have hung.
crashBlind:
; ---- And with the colours ruined as well ----
;
; A known mode is only half a known screen. This makes the ink of attribute one the same as
; its paper, which is what a program that wrote its own palette can easily leave behind -
; and a message printed into that is perfectly present and completely invisible.
INIA 0d4
OUTA 0xE3
INIA 0x30
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; Video memory as bank four.
INIA 0d4
OUTA 0xE3
INIA 0xFC
OUTA 0xE4
INIA 0x40
OUTA 0xE5 ; 0xFC40, the two entries attribute one draws from.
RSTA
INIB 0d8
crashBlindWipe:
OUTA 0xE9
DECB
BNB crashBlindWipe ; Both of them black, ink and paper alike.
INIA 0x02
OUTA 0x31
crashOpcode:
0x00 ; Not an instruction, and never will be.
crashService:
SWI 0d40 ; Forty is nobody's.
crashBank:
INIA 0d9 ; Nothing is registered there.
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2
INIA 0d1
OUTA 0xE3 ; Into Data Memory.
RSTA
OUTA 0xE4
OUTA 0xE5
OUTA 0xE6
INIA 0d16
OUTA 0xE7
INIA 0x01
OUTA 0xE8 ; Blit, from a bank that is not there.
crashDevice:
INIA 0x02 ; Interrupt me when the console has something to say.
OUTA 0x02
SIF
crashWait:
; Never touches the console, so whatever happens next came from outside.
BRI crashWait
#Data
#Base 0x3000
WordOpcode:
"opcode"
WordService:
"service"
WordBank:
"bank"
WordDevice:
"device"
WordBlind:
"blind"
Usage:
"Crash opcode | service | bank | device | blind"
Argument:
#Reserve 0d16
#Include text.asm
+377
View File
@@ -0,0 +1,377 @@
; A ball that goes behind some pillars and in front of others.
;
; Four pillars, each ONE 8 by 8 TILE stretched to its own width and height, at four different
; distances. One ball, moving across all of them. Which pillars it passes behind is decided
; per screen column by the depth buffer, and that is the thing worth watching: the ball is in
; front of the far pillars and behind the near ones IN THE SAME FRAME, without the program
; sorting anything or drawing anything twice.
;
; ---- Why that needs a buffer rather than an ordering ----
;
; Sprites are drawn in table order, so a program can put one in front of another by numbering
; them. That is enough when things are flat. It stops being enough the moment a thing is
; nearer than one part of the scenery and further than another - a ball halfway past a pillar
; is in front of the floor beside it and behind the pillar itself, and there is no order of
; two sprites that means "behind, on those columns only".
;
; So the scenery says how far away it is, a column at a time, and the ball says how far away
; IT is. The device compares them per pixel.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; The atlas: the tiles, the sprite table and the depth buffer are all in it.
INIA 0d4
OUTA 0xE3
INIA 0x30
OUTA 0xE2
INIA 0x03
OUTA 0xE8
SETD.0 Message
SWI osPrintString
INIA 0x0A
OUTA 0x00
CALL putArt
CALL clearDepth
CALL putPillars
CALL putBall
INIA 0x01
OUTA 0x02 ; Key mode.
everyFrame:
CALL waitFrame
CALL stepBall
CALL moveBall
INA 0x01
INIB 0x01 ; READY
AND
BRQ everyFrame
INA 0x00
RSTA
OUTA 0x02 ; Line mode. The sprites and the buffer are the system's to clear.
SWI osExit
; ---- Two tiles ----
;
; Tile 200 is the ball, which has a shape and so comes out of the Data Segment. Tile 201 is
; the pillar, which is a solid block and so is a Fill: 64 bytes of index one, no art needed.
putArt:
INIA 0x01
OUTA 0xE0 ; SourceBank: Data Memory.
SETD.1 BallArtAt
SETD.0 BallArt
STD.0.1
LDA.1
OUTA 0xE1
INCD.1
LDA.1
OUTA 0xE2
INIA 0d4
OUTA 0xE3
INIA 0x32
OUTA 0xE4 ; Tile 200 is at 200 times 64, which is 0x3200.
RSTA
OUTA 0xE5
OUTA 0xE6
INIA 0x40
OUTA 0xE7
INIA 0x01
OUTA 0xE8 ; Blit.
INIA 0x32
OUTA 0xE4
INIA 0x40
OUTA 0xE5 ; Tile 201 is 64 bytes further on, at 0x3240.
INIA 0x01
OUTA 0xE2 ; Index ONE, which is the only index a scheme gives a colour to:
; the default palette sets each scheme's paper and ink and
; nothing between them.
RSTA
OUTA 0xE6
INIA 0x40
OUTA 0xE7
INIA 0x02
OUTA 0xE8 ; Fill.
RET
; ---- The buffer, emptied ----
;
; Nought in a column means nothing is there. It is the program's buffer and nobody clears it
; between programs, so a program that means "nothing yet" has to say so.
clearDepth:
INIA 0d4
OUTA 0xE3
INIA 0xD0
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE2 ; Fill takes the byte it writes from SourceLow.
INIA 0x02
OUTA 0xE6
INIA 0x80
OUTA 0xE7 ; 640 bytes, one a column.
INIA 0x02
OUTA 0xE8
RET
; ---- The pillars ----
;
; Four records of seven bytes: x, y, height, depth. Each becomes a sprite entry and a run of
; the depth buffer saying how far away that part of the scenery is.
putPillars:
INIA 0d4
SETD.0 PillarLeft
STA.0 ; Sprite one is at 0xC010; sprite nought is the ball.
INIA 0x10
SETD.0 EntryLow
STA.0
SETD.3 Pillars
putOnePillar:
; ---- The entry, written straight through ----
;
; Sixteen bytes out of one port with the address named once, because the controller's Data
; port steps on after every byte. DP3 walks the record and survives the calls below.
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
SETD.0 EntryLow
LDA.0
OUTA 0xE5
INIA 0xC9
OUTA 0xE9 ; Tile 201, the pillar block.
INIA 0x01
OUTA 0xE9 ; Attribute one.
; Kept as they go past, because putPillarDepth needs the column and a Data Pointer cannot
; be walked backwards to find it again.
LDA.3
OUTA 0xE9 ; X low.
SETD.0 PillarXLow
STA.0
INCD.3
LDA.3
OUTA 0xE9 ; X high.
SETD.0 PillarXHigh
STA.0
INCD.3
LDA.3
OUTA 0xE9 ; Y low.
INCD.3
LDA.3
OUTA 0xE9 ; Y high.
INCD.3
INIA 0x11
OUTA 0xE9 ; One tile by one, which is the art. The size below is the look.
RSTA
OUTA 0xE9 ; No flags.
INIA 0d32
OUTA 0xE9
RSTA
OUTA 0xE9 ; Thirty two pixels wide, whatever the tile is.
LDA.3
OUTA 0xE9 ; Height low.
INCD.3
LDA.3
OUTA 0xE9 ; Height high.
INCD.3
; ---- And a depth of NOUGHT, which is not the pillar's distance ----
;
; It is tempting to put the pillar's own distance here, and it is wrong: the depth buffer
; is what has already been DRAWN, and a sprite's depth is a QUESTION ASKED OF IT. A pillar
; that both wrote 20 into its own columns and carried 20 would be asked whether it was in
; front of itself, and 20 is not nearer than 20, so it would vanish. Which it did.
;
; Scenery writes the buffer. It does not ask.
RSTA
OUTA 0xE9
CALL putPillarDepth
; On to the next record and the next entry.
INCD.3
SETD.0 EntryLow
LDA.0
INIB 0d16
CCF
ADD
STQ.0
SETD.0 PillarLeft
LDA.0
DECA
STA.0
BNA putOnePillar
RET
; ---- How far away those thirty two columns are ----
;
; A Fill, because the whole run is one number: the pillar is flat on, so every column of it
; is the same distance. DP3 is on the depth byte of the record and stays there.
;
; The buffer begins at 0xD000, whose low byte is nought - so the column's low byte IS the
; address's low byte, and its high byte only has to be added to 0xD0. No sixteen bit sum.
putPillarDepth:
LDA.3
OUTA 0xE2 ; The byte to fill with, which is the depth.
INIA 0d4
OUTA 0xE3
SETD.0 PillarXHigh
LDA.0
INIB 0xD0
CCF
ADD
OUTQ 0xE4
SETD.0 PillarXLow
LDA.0
OUTA 0xE5
RSTA
OUTA 0xE6
INIA 0d32
OUTA 0xE7 ; Thirty two columns of it.
INIA 0x02
OUTA 0xE8
RET
; ---- The ball ----
;
; Sprite nought, so it is in front of every pillar as far as the TABLE is concerned. What
; puts it behind some of them is its depth and nothing else, which is the whole point.
putBall:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0xC8
OUTA 0xE9 ; Tile 200.
INIA 0x02
OUTA 0xE9 ; Attribute two.
RSTA
OUTA 0xE9
OUTA 0xE9 ; X, low then high.
INIA 0d230
OUTA 0xE9
RSTA
OUTA 0xE9 ; Y.
INIA 0x11
OUTA 0xE9 ; One tile by one.
RSTA
OUTA 0xE9 ; No flags.
INIA 0d48
OUTA 0xE9
RSTA
OUTA 0xE9
INIA 0d48
OUTA 0xE9
RSTA
OUTA 0xE9 ; Forty eight by forty eight, from eight by eight of art.
INIA 0d45
OUTA 0xE9 ; And forty five away, which is between the pillars.
RET
waitFrame:
INA 0x30
INIB 0x01
AND
BRQ waitFrame
RET
; Two pixels to the right, wrapping past the far side. Sixteen bits in two bytes, so the high
; one steps only when the low one came round to nought.
stepBall:
SETD.0 BallX
LDA.0
INCA
INCA
STA.0
BNA stepCheck
SETD.0 BallXHigh
LDA.0
INCA
STA.0
stepCheck:
SETD.0 BallXHigh
LDA.0
INIB 0x02
CCF
SUB
BNQ stepDone ; Not past 0x0200, so nowhere near the end.
RSTA
SETD.0 BallX
STA.0
SETD.0 BallXHigh
STA.0
stepDone:
RET
moveBall:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
INIA 0x02
OUTA 0xE5 ; X is bytes two and three of the entry.
SETD.0 BallX
LDA.0
OUTA 0xE9
SETD.0 BallXHigh
LDA.0
OUTA 0xE9
RET
#Data
#Base 0x3000
Message:
"A ball, behind the near pillars and in front of the far ones. Press a key."
; x, y, height, depth. Nearer pillars are taller, which is the only thing making this look
; like distance rather than like four bars.
Pillars:
0x50 0x00 0x64 0x00 0xF0 0x00 0d20 ; x 80, y 100, 240 tall, near
0xC8 0x00 0xDC 0x00 0x78 0x00 0d60 ; x 200, y 220, 120 tall, far
0x68 0x01 0xA0 0x00 0xB4 0x00 0d30 ; x 360, y 160, 180 tall, nearish
0xF4 0x01 0x04 0x01 0x50 0x00 0d80 ; x 500, y 260, 80 tall, furthest
; Where putPillarDepth reads the column from. DP3 is on the record's depth byte by then, and
; a Data Pointer cannot be read backwards, so the two halves are kept here as they go past.
PillarXLow:
0x00
PillarXHigh:
0x00
PillarLeft:
0x00
EntryLow:
0x00
BallArt:
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
BallArtAt:
#Reserve 0d2
BallX:
0x00
BallXHigh:
0x00
+118 -16
View File
@@ -51,11 +51,18 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; ---- Room for a PATH, not a name ----
;
; Sixty-three, which was twenty-three. That was the right size when everything lived in
; the root and a name was a name. The disk now carries the whole source tree, so
; "/Source/CosmOS/Assembler/classify.asm" is an ordinary thing to type - thirty-seven
; characters, and it was being cut down to a name that meant something else, or nothing.
; The shell reads sixty-three characters of command line, so nothing longer can arrive.
SETD.0 FileName SETD.0 FileName
INIB 0d23 INIB 0d63
SWI osArgument SWI osArgument
SETD.0 FileName SETD.0 FileName
LDA.0 LDA.0
@@ -75,12 +82,38 @@ start:
RSTA RSTA
STA.0 STA.0
RSTA
SETD.0 TooLong
STA.0
; ---- Cleared here rather than in the Data Segment ----
;
; A program is loaded once and may be run many times: "load Edit.sbx" then "run" twice is
; two sessions over one copy of the data, so anything a session changes has to be put back
; by the session and not by the loader. A zero written in the Data Segment is the state
; this program starts in the FIRST time and never again.
SETD.0 IsNew
STA.0
CALL loadFile CALL loadFile
SETD.0 TooLong
LDA.0
BNA tooLongToEdit
SETD.0 FileName SETD.0 FileName
SWI osPrintString SWI osPrintString
SETD.0 CommaText SETD.0 CommaText
SWI osPrintString SWI osPrintString
; A file that was not there is a new document, which this is the way to start - but say so,
; rather than saying nothing and letting it look like an empty one that already existed.
SETD.0 IsNew
LDA.0
BRA sayCount
SETD.0 NewText
SWI osPrintString
CALL newLine
BRI announced
sayCount:
CALL countLines CALL countLines
MVQA MVQA
CALL printByte CALL printByte
@@ -95,6 +128,7 @@ oneLine:
sayLines: sayLines:
SWI osPrintString SWI osPrintString
CALL newLine CALL newLine
announced:
commandLoop: commandLoop:
SETD.0 PromptText SETD.0 PromptText
@@ -161,12 +195,21 @@ commandArgument:
BRI commandLoop BRI commandLoop
quit: quit:
RSTA
SWI osExit
tooLongToEdit:
SETD.0 TooLongText
SWI osPrintString
CALL newLine
INIA 0d1
SWI osExit SWI osExit
noName: noName:
SETD.0 NoNameText SETD.0 NoNameText
SWI osPrintString SWI osPrintString
CALL newLine CALL newLine
INIA 0d2
SWI osExit SWI osExit
@@ -192,7 +235,7 @@ insertLoop:
SETD.0 EnteringText SETD.0 EnteringText
SWI osPrintString SWI osPrintString
SETD.0 Entry SETD.0 Entry
INIB 0d80 INIB 0d128
SWI osReadLine SWI osReadLine
INA 0x01 INA 0x01
INIB 0x02 ; ENDED INIB 0x02 ; ENDED
@@ -232,7 +275,7 @@ doChange:
SETD.0 EnteringText SETD.0 EnteringText
SWI osPrintString SWI osPrintString
SETD.0 Entry SETD.0 Entry
INIB 0d80 INIB 0d128
SWI osReadLine SWI osReadLine
INA 0x01 INA 0x01
INIB 0x02 ; ENDED INIB 0x02 ; ENDED
@@ -529,7 +572,6 @@ listStep:
PSHD.3 PSHD.3
POPD.0 POPD.0
DPUP.0 0d03 DPUP.0 0d03
SETD.1 Leftover
LDA.1 LDA.1
BRA listEmpty BRA listEmpty
listChars: listChars:
@@ -563,7 +605,7 @@ loadFile:
SETD.0 FileName SETD.0 FileName
SETD.1 0x40 0x00 SETD.1 0x40 0x00
SWI osFileRead SWI osFileRead
BNQ loadNothing BNQ loadNew
; How many bytes came back. The service says so in DP3, which is one of the two things a ; How many bytes came back. The service says so in DP3, which is one of the two things a
; service is allowed to answer in, and a file that fits in memory has a length that fits ; service is allowed to answer in, and a file that fits in memory has a length that fits
@@ -599,6 +641,29 @@ splitStep:
XOR XOR
BRQ splitLine BRQ splitLine
; ---- Room for it ----
;
; THERE WAS NO CHECK HERE AT ALL, and Entry is followed in memory by TextHead and
; ArenaFree - the head of the document and the pointer the line allocator hands out. A
; line longer than the buffer wrote characters over both, so the list head pointed into
; the middle of the text and the allocator handed out an address inside the file.
;
; What that looked like: a thirty one line file opened as three, one of them cut short.
; Then, opening it a second time, a list that led back into itself and a machine that
; walked it for ever - the emulator still running and the machine never answering again.
;
; Typing a long line was always safe, because osReadLine is told how much room there is.
; Only the file being read went unchecked, which is why a new document behaved and a
; source file did not.
PSHA
SETD.2 EntryLength
LDA.2
INIB 0d128
CCF
SUB
POPA ; The character back, and Q still says whether there is room.
BRQ splitTooLong
STA.1 ; A is still the character; an ALU operation does not touch it. STA.1 ; A is still the character; an ALU operation does not touch it.
INCD.1 INCD.1
SETD.2 EntryLength SETD.2 EntryLength
@@ -626,17 +691,42 @@ splitOn:
CALL takeOneOff CALL takeOneOff
BRI splitStep BRI splitStep
splitTooLong:
; NOT TRUNCATED. This is an editor: a line it shortened here would be written back
; shortened, and the file would be damaged by having been looked at. Refusing leaves it
; exactly as it was.
INIA 0x01
SETD.2 TooLong
STA.2
RET
splitLast: splitLast:
; A file that does not end in a newline still has a last line in it. ; A file that does not end in a newline still has a last line in it.
SETD.2 EntryLength SETD.2 EntryLength
LDA.2 LDA.2
BRA loadNothing BRA loadDone
RSTA RSTA
STA.1 STA.1
SETD.0 Entry SETD.0 Entry
CALL makeNode CALL makeNode
CALL appendNode CALL appendNode
loadNothing: ; ---- Where a load finishes ----
;
; NOT WHERE ONE FAILS, which the old name for this said and which is a different thing: every
; successful load arrives here too, once its last line has been dealt with. That is worth
; being plain about, because a flag set here on the strength of the name was set on every
; file that opened perfectly well.
loadDone:
RET
; A name that is not on the disk is a new document rather than a mistake, which is what makes
; this the way to start one. Remembered, because "0 lines" is what an empty file that IS on
; the disk also says, and a name typed slightly wrong looks exactly like the document you
; meant to open - right up until you save it somewhere new.
loadNew:
SETD.0 IsNew
INIA 0x01
STA.0
RET RET
; DP3 is a node. Puts it on the end of the list. ; DP3 is a node. Puts it on the end of the list.
@@ -653,12 +743,12 @@ appendNode:
; DP2 is a two byte count. Takes one off it. ; DP2 is a two byte count. Takes one off it.
takeOneOff: takeOneOff:
DPUP.2 0d01 INCD.2
LDA.2 LDA.2
DECA DECA
STA.2 STA.2
BNC takeOneDone ; No borrow, so the high half is untouched. BNC takeOneDone ; No borrow, so the high half is untouched.
DPDN.2 0d01 DECD.2
LDA.2 LDA.2
DECA DECA
STA.2 STA.2
@@ -723,7 +813,6 @@ writeOut:
CCF CCF
SUB SUB
MVQA MVQA
SETD.0 WroteSize
STA.0 ; WroteSize is now a count of bytes, which is what gets printed. STA.0 ; WroteSize is now a count of bytes, which is what gets printed.
; And into the two registers the service takes a size in. ; And into the two registers the service takes a size in.
@@ -766,12 +855,12 @@ printWord:
; DP0 is a two byte number, A is a byte. Adds the one to the other. ; DP0 is a two byte number, A is a byte. Adds the one to the other.
addByteToWord: addByteToWord:
DPUP.0 0d01 INCD.0
LDB.0 LDB.0
CCF CCF
ADD ADD
STQ.0 STQ.0
DPDN.0 0d01 DECD.0
LDA.0 LDA.0
RSTB RSTB
ADD ADD
@@ -780,7 +869,7 @@ addByteToWord:
#Data #Data
#Base 0x1000 #Base 0x3000
Break: Break:
0x0A 0x00 0x0A 0x00
@@ -792,6 +881,8 @@ ColonText:
": " ": "
CommaText: CommaText:
", " ", "
NewText:
"new file"
LinesText: LinesText:
" lines" " lines"
LineText: LineText:
@@ -812,13 +903,24 @@ NeedsLineText:
"which line?" "which line?"
NoWriteText: NoWriteText:
"it would not write" "it would not write"
TooLongText:
"a line in it is longer than this can edit, so it has not been opened"
; Whether the name was not on the disk, which makes this a new document rather than one
; being opened. Zero until loadFile finds out otherwise.
IsNew:
0x00
FileName: FileName:
#Reserve 0d24 #Reserve 0d64
Command: Command:
#Reserve 0d41 #Reserve 0d41
; A hundred and twenty eight and the zero that ends it, which is what a line is everywhere
; else on this machine - the same number configuration files use, rather than a second
; answer to a question already answered.
Entry: Entry:
#Reserve 0d81 #Reserve 0d129
TooLong:
0x00
TextHead: TextHead:
0x00 0x00 0x00 0x00
+3 -2
View File
@@ -4,7 +4,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; Swap ValueB and ValueA. ; Swap ValueB and ValueA.
@@ -67,11 +67,12 @@ start:
BRI start BRI start
end: end:
CALL lineFeed CALL lineFeed
RSTA
SWI osExit SWI osExit
#Data #Data
#Base 0x1000 #Base 0x3000
ValueA: ValueA:
; Low byte, high byte. ; Low byte, high byte.
+3 -2
View File
@@ -4,7 +4,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; Swap ValueB and ValueA. ; Swap ValueB and ValueA.
@@ -123,11 +123,12 @@ start:
end: end:
CALL lineFeed CALL lineFeed
;HALT ;HALT
RSTA
SWI osExit SWI osExit
#Data #Data
#Base 0x1000 #Base 0x3000
ValueA: ValueA:
; Lowest byte ... Highest byte. ; Lowest byte ... Highest byte.
+5 -5
View File
@@ -4,11 +4,11 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; Load our initial values into A and B. ; Load our initial values into A and B.
INIA 0x00 RSTA
CALL printByteDecimal CALL printByteDecimal
CALL blankSpace CALL blankSpace
; Move the value into B. ; Move the value into B.
@@ -25,8 +25,7 @@ start:
PSHA PSHA
POPB POPB
; Copy Q into A ; Copy Q into A
PSHQ MVQA
POPA
; Print A. ; Print A.
CALL printByteDecimal CALL printByteDecimal
CALL blankSpace CALL blankSpace
@@ -34,10 +33,11 @@ start:
end: end:
CALL lineFeed CALL lineFeed
;HALT ;HALT
RSTA
SWI osExit ; Return to CosmOS. SWI osExit ; Return to CosmOS.
#Data #Data
#Base 0x1000 #Base 0x3000
#Include print.asm #Include print.asm
+8 -2
View File
@@ -24,7 +24,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; ---- Write it ---- ; ---- Write it ----
@@ -101,33 +101,39 @@ atEnd:
BRQ stillThere BRQ stillThere
SETD.0 GoneText SETD.0 GoneText
SWI osPrintString SWI osPrintString
RSTA
SWI osExit SWI osExit
stillThere: stillThere:
SETD.0 StillText SETD.0 StillText
SWI osPrintString SWI osPrintString
RSTA
SWI osExit SWI osExit
noSave: noSave:
SETD.0 NoSaveText SETD.0 NoSaveText
SWI osPrintString SWI osPrintString
INIA 0d1
SWI osExit SWI osExit
noRead: noRead:
SETD.0 NoReadText SETD.0 NoReadText
SWI osPrintString SWI osPrintString
INIA 0d1
SWI osExit SWI osExit
noRename: noRename:
SETD.0 NoRenameText SETD.0 NoRenameText
SWI osPrintString SWI osPrintString
INIA 0d1
SWI osExit SWI osExit
noDelete: noDelete:
SETD.0 NoDeleteText SETD.0 NoDeleteText
SWI osPrintString SWI osPrintString
INIA 0d1
SWI osExit SWI osExit
#Data #Data
#Base 0x1000 #Base 0x3000
Name: Name:
"kept.txt" "kept.txt"
+148
View File
@@ -0,0 +1,148 @@
; The screen nobody is looking at.
;
; The screen brings two map banks and shows one of them. Everything this program draws goes
; into the other, so the picture does not change at all while it is being built - and then
; one byte out of one port shows the whole of it at once.
;
; ---- Why that is worth a port ----
;
; A screen drawn where it can be seen is seen half drawn. A game 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. The machines this one is pretending to
; be had the same problem and solved it the same way, except that they had to catch the few
; lines between one frame and the next to do the swap in. Here a frame is drawn from one bank
; in one go, so a flip cannot land halfway through one and there is nothing to race.
;
; ---- What it costs ----
;
; A whole bank, which is 64K of somebody's memory - and nothing, which is the point. The
; second screen is memory the device brought, the same as the first, so a program that wants
; it registers it and a program that does not never pays for it.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; ---- The one bank this touches ----
;
; Six: three is the disk's and four and five are the ones the system registers to save a
; screen with, so six is the first number free. Nothing hands these out - see the table in
; the CosmOS README.
INIA 0d6
OUTA 0xE3
INIA 0x3B
OUTA 0xE2
INIA 0x03
OUTA 0xE8
; ---- And it does NOT take the screen ----
;
; osTakeScreen saves what is on the screen and puts it back at exit, and this program
; called it at first. That was wrong in a way worth keeping written down: it saves the
; screen AS IT WAS BEFORE, so the line printed below - the line whose whole job is to still
; be there afterwards - was wiped out by the restore that was meant to be tidy.
;
; It was not needed either. NOTHING THIS TOUCHES IS THE SHELL'S: the only memory it writes
; is the map of the screen nobody is looking at, and the only register it leaves changed is
; which screen that is - which the system puts back itself.
;
; A program that wants a screen saved should ask. A program that damages nothing should
; not, and asking anyway is not free - it costs the screen it was standing on.
SETD.0 Message
SWI osPrintString
INIA 0x0A
OUTA 0x00 ; The assembler has no escapes; a newline is a byte.
; Key mode, so a key arrives when it is pressed rather than when Return is.
INIA 0x01
OUTA 0x02
; ---- Every cell of the other screen, in one command ----
;
; TEN, and the reason is worth the paragraph. Fill writes one byte, and a cell is two, so
; whatever is filled with is BOTH the tile and the attribute - there is no filling a map
; with a tile and a colour that are different numbers.
;
; Ten as a tile is an asterisk, because the font begins at the space and glyph n is
; character n plus thirty two. Ten as an attribute is one of the eight REVERSED schemes,
; whose paper is a colour and whose ink is black. So the screen comes out green, covered in
; black asterisks, without a single tile being redefined.
;
; The high nibble stays at nought, which the manual asks for: it is reserved. That is the
; real constraint on which byte this can be - it has to be under sixteen to leave the
; nibble alone, and eight or over to land on a reversed scheme whose PAPER is the colour.
;
; Which means THIS PROGRAM WRITES NOTHING BUT THE SCREEN NOBODY IS LOOKING AT. It was
; drawn with a tile of its own first, and that tile was one the system copies the font back
; over at every exit - so the screen it had filled turned blank the moment it left, and a
; check that it had been put back could not tell a restored screen from an abandoned one.
; A picture that depends on the atlas does not survive leaving.
INIA 0d6
OUTA 0xE3
INIA 0x40
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0x0A
OUTA 0xE2 ; Fill takes the byte it writes from SourceLow.
INIA 0x80
OUTA 0xE6
RSTA
OUTA 0xE7 ; The whole map, which is 0x8000 bytes.
INIA 0x02
OUTA 0xE8
; NOTHING HAS CHANGED ON THE SCREEN. Every byte of that went where nobody can see it, and
; the line printed above is still sitting there to prove it - which is the reason this
; waits here rather than flipping straight away. What a back buffer is for is not visible
; in the flip; it is visible in the time before one.
CALL waitKey
; And this is the whole of showing it.
INIA 0x01
OUTA 0x3C
CALL waitKey
; ---- Which screen is showing is NOT put back here ----
;
; On purpose, and it is the one thing in this program worth arguing about. The system
; restores it at exit, the same way it restores the cursor and the ink, and for the same
; reason: a program that FAULTED while flipped could not have put it back, and a shell that
; only came out right for programs which remembered would be a shell that came out wrong
; the day one crashed. What the person is looking at belongs to the system.
;
; The console mode below IS put back, because that is this program's own borrowing rather
; than something the system hands out.
RSTA
OUTA 0x02 ; Line mode again.
SWI osExit
; ---- A key, asked for rather than waited on ----
;
; The console holds one until somebody wants it, so nothing pressed while the map was being
; filled is lost - it is sitting there and this returns immediately, which is right. A key
; pressed is a key meant for this program.
waitKey:
INA 0x01
INIB 0x01 ; READY
AND
BRQ waitKey
INA 0x00 ; Taken, so the shell is not handed a key meant for this.
RET
#Data
#Base 0x3000
; One line and no newline in it. A string literal is terminated where it ends, so a second
; one after a 0x0A byte would never be reached - printing stops at the first terminator. The
; newline is written to the console directly instead.
Message:
"A screen is drawn where you cannot see it. A key shows it, another comes back."
+466
View File
@@ -0,0 +1,466 @@
; 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 nothing had touched it: the console uses it,
; but only ever to put a letter in a cell, which is the one thing it can do that a plain
; character display could do too.
;
; This redefines a tile, fills a map bigger than the screen with it, and then scrolls that
; map by writing ONE BYTE A FRAME. No memory moves. The rows above and below the screen are
; already there, so what a scroll costs is not the 2,000 bytes of a screenful but the one
; byte that says which row is on top.
;
; ---- Where its tiles live ----
;
; At 200, and the font is why. The machine wakes with the font in tile memory - glyph n at
; tile n, for 135 of the 256 - so a program that starts writing tiles at zero paints over
; the alphabet and the shell it is going to hand the machine back to. Above 135 is empty and
; nobody else's, so nothing here has to be put back afterwards except the map.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; ---- Reaching video memory ----
;
; The CPU cannot touch it. It belongs to the device, and the only way in is to give it a
; bank number and go through the memory controller - the same as the disk's buffer.
;
; FOUR, BECAUSE THREE IS THE DISK'S. Bank numbers are one namespace for the whole machine
; and nothing hands them out: 0 is Program Memory, 1 is Data, 2 is the bank table, and
; CosmOS gives 3 to the disk's buffer when it mounts. This asked for 3, which does not
; fail - it succeeds, and the disk's buffer quietly becomes the screen. Every read the
; filesystem made after that came out of video memory, so the shell found an empty disk
; and could not start anything by name. Nothing said a word.
; TWO BANKS, because the screen has two. The atlas holds the tiles and the palette and is
; written when a program starts; the screen holds the map and is written as things move.
; They are separate memories, so a bank number reaches one or the other and never both -
; which is the whole of what this program had to learn when they were split apart.
INIA 0d4
OUTA 0xE3 ; DestBank: the number it will answer to.
INIA 0x30
OUTA 0xE2 ; SourceLow: the port of the device that owns it.
INIA 0x03
OUTA 0xE8 ; RegisterBank. Four is the atlas.
INIA 0d5
OUTA 0xE3
INIA 0x3A
OUTA 0xE2 ; And the port that owns the screen.
INIA 0x03
OUTA 0xE8 ; RegisterBank. Five is the map.
; ---- Asking for the screen back afterwards ----
;
; Everything below overwrites a tile, all sixteen colour schemes and every cell of the map,
; and none of that is this program's to keep. The system puts it somewhere and gives it back
; at exit - and if it says it cannot, this carries on anyway, because it did before there
; was anywhere to put it.
SWI osTakeScreen
MVQA
SETD.0 ScreenKept
STA.0 ; Nought means the system will put it back.
CALL putTile
CALL putPalette
CALL putMap
; Key mode, so that a key arrives when it is pressed rather than when Return is. Put back
; before this returns, and CosmOS puts it back too if a program forgets.
INIA 0x01
OUTA 0x02
; ---- The loop ----
;
; A frame, then one pixel down and one across. This used to move a whole cell every fourth
; frame, because a cell was as fine as the screen could be moved - eight pixels at a time,
; which reads as the picture jumping rather than travelling.
everyFrame:
CALL waitFrame
; Anything typed ends it. Asked for, never waited for: the console holds the key until
; somebody wants it, so nothing pressed between frames is lost.
INA 0x01
INIB 0x01 ; READY
AND
BNQ finished
; ---- A pixel down, and the cell it belongs to ----
;
; Fine is the low three bits of the register and does not carry, so this does: eight steps
; inside the cell and then one step of the origin. The AND is both the wrap and the test -
; Q coming out as nought is exactly the moment the cell boundary was crossed.
SETD.0 FineDown
LDA.0
INCA
INIB 0x07
AND
STQ.0
OUTQ 0x38
BNQ stepAcross
; The map is 128 rows against a screen of 25 or 50, so the origin walks a ring: what leaves
; the top has not gone anywhere and comes back round.
SETD.0 OriginDown
LDA.0
INCA
INIB 0x7F
AND
STQ.0
OUTQ 0x34
stepAcross:
; And the same sideways, which is the axis that did not exist at all until now. There are
; 128 columns against the 80 shown, so this ring is shallower but it is the same ring.
SETD.0 FineAcross
LDA.0
INCA
INIB 0x07
AND
STQ.0
OUTQ 0x37
BNQ everyFrame
SETD.0 OriginAcross
LDA.0
INCA
INIB 0x7F
AND
STQ.0
OUTQ 0x36
BRI everyFrame
finished:
; The key that stopped it, taken so the shell does not find it waiting.
INA 0x00
; ---- Putting the screen back ----
;
; ---- What used to be here ----
;
; Four scroll registers put back, the whole map filled with spaces, the cursor sent home,
; and palette bank 0 written out by hand - and it was STILL wrong, because the other fifteen
; banks kept this program's colours and there was nowhere to have put the real ones.
;
; All of it is osTakeScreen's now, and it gives back what was actually there rather than
; what a clean machine looks like.
;
; ---- Unless it said no ----
;
; A machine with no volatile drive has nowhere to keep a screen, and answers so. Being told
; no is not a fault and not a reason to stop: it means doing what this program did before
; there was anywhere to put one, which is leaving a blank screen rather than a grid with
; somebody's prompt printed into it.
SETD.0 ScreenKept
LDA.0
BRA gridScreenKept
RSTA
OUTA 0x34
OUTA 0x36 ; The origins, or the shell looks at a corner of the map.
INIA 0d5
OUTA 0xE3
INIA 0x40
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE2 ; Tile nought is the space and attribute nought is plain.
INIA 0x80
OUTA 0xE6
RSTA
OUTA 0xE7
INIA 0x02
OUTA 0xE8 ; Fill the whole map with it.
; And the colours the machine wakes up in, which is as near as this can get to the ones it
; took: grey on black is what plain text has always been here.
INIA 0d4
OUTA 0xE3
INIA 0xFC
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
INIA 0xD8
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
RSTA
OUTA 0xE9
INIA 0x01
OUTA 0x05 ; Cleared, which also puts the cursor home.
gridScreenKept:
RSTA
OUTA 0x02 ; Line mode, the way it was found.
RSTA ; splitlint[redundant-assignment]: an exit status, not a mode
SWI osExit
; ---- A frame ----
;
; Asked for rather than waited on with an interrupt. Polling costs a program nothing it
; needs here and saves installing a vector, and the status bit is honest: reading it is what
; answers it, so this cannot see the same frame twice.
waitFrame:
INA 0x30
INIB 0x01
AND
BRQ waitFrame
RET
; ---- The tile ----
;
; Blitted rather than written a byte at a time, because it is already sixty four bytes of
; Data Segment and the controller will move it in one command. Tile n starts at n times 64,
; so tile 200 starts at 12,800, which is 0x3200.
putTile:
INIA 0x01
OUTA 0xE0 ; SourceBank: Data Memory.
; The pointer BEFORE storing through it. Written the other way round the first time, which
; assembles perfectly and stores the address into wherever DP1 was last left - so the blit
; read its sixty four bytes from nowhere in particular and tile 200 came out as noise.
SETD.1 TileArtAt
SETD.0 TileArt
STD.0.1
LDA.1
OUTA 0xE1
INCD.1
LDA.1
OUTA 0xE2
INIA 0d4
OUTA 0xE3
INIA 0x32
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE6
INIA 0d64
OUTA 0xE7
INIA 0x01
OUTA 0xE8 ; Blit.
RET
; ---- The colours ----
;
; The tile is drawn in indices 0 and 1, and the low nibble of a cell's attribute is ADDED to
; every index in it, sixteen at a time. So the same sixty four bytes appear in sixteen colour
; schemes, and what this writes is those schemes: entry 16n is the ground and 16n+1 the line.
; Nothing is duplicated to get them.
;
; ---- Nothing here works out an address ----
;
; The first try computed where entry 16n lives - 0xFC00 plus 64n - and was wrong twice for
; the same reason, which is that this machine cannot multiply and pretending otherwise is
; where the bugs go. 64n reaches 960, so the address spans four pages and the high byte moves
; too; and doubling A by adding B to it needs B to hold A first, which RSTB is the opposite
; of.
;
; So it writes all 256 entries in order and never computes anything. The controller's Data
; port steps the address on after every byte, so the whole palette is one sweep of 1,024
; writes with no arithmetic in it at all. The colours that change do so by adding sixteen to
; a running value, which is the same reason.
putPalette:
INIA 0d4
OUTA 0xE3
INIA 0xFC
OUTA 0xE4
RSTA
OUTA 0xE5
SETD.0 Scheme
STA.0
SETD.0 LineRed
STA.0
INIA 0xFF
SETD.0 LineGreen
STA.0
everyScheme:
; Entry 16n, the ground: the same dark under every scheme.
INIA 0d16
OUTA 0xE9
OUTA 0xE9
INIA 0d24
OUTA 0xE9
RSTA
OUTA 0xE9
; Entry 16n+1, the line: more red and less green the further down the map it is, so that
; scrolling is visibly going somewhere rather than showing the same row again.
SETD.0 LineRed
LDA.0
OUTA 0xE9
SETD.0 LineGreen
LDA.0
OUTA 0xE9
INIA 0d96
OUTA 0xE9
RSTA
OUTA 0xE9
; The fourteen this tile never asks for. Written anyway, because the sweep is what keeps
; the address right and skipping them would mean working one out.
INIA 0d14
SETD.0 Spare
STA.0
everySpare:
RSTA
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
SETD.0 Spare
LDA.0
DECA
STA.0
BNA everySpare
SETD.0 LineRed
LDA.0
INIB 0d16
CCF
ADD
STQ.0
SETD.0 LineGreen
LDA.0
CCF
SUB ; B is still sixteen, from the red just above.
STQ.0
SETD.0 Scheme
LDA.0
INCA
STA.0
CCF
SUB ; And still sixteen here, which is also how many schemes there are.
BNQ everyScheme
RET
; ---- The map ----
;
; All 128 rows, not the 25 the screen shows. That is the whole point of a map bigger than
; the screen: the rows above and below are already drawn, so scrolling is a change of origin
; rather than a change of anything.
;
; A ROW IS A PAGE, which is why the row number is written straight into DestHigh and the
; column arithmetic disappears. The controller's Data port steps the address on after every
; byte, so a row is a loop over two writes with no address handling in it at all.
putMap:
RSTA
SETD.0 MapRow
STA.0
everyRow:
INIA 0d5
OUTA 0xE3
SETD.0 MapRow
LDA.0
INIB 0x40
CCF
ADD
OUTQ 0xE4 ; The map starts at 0x4000 and a row is a page.
RSTA
OUTA 0xE5
; The attribute is the row number's low nibble, so the schemes band down the map and
; repeat every sixteen rows. DP0 is still MapRow, from the row's address above.
LDA.0
INIB 0x0F
AND
SETD.0 RowAttribute
STQ.0
; ---- The map's width, which is not the screen's ----
;
; A hundred and twenty-eight, because that is how many cells a map row holds: 256 bytes at
; two bytes a cell, whatever mode the screen is in. It is a property of video memory rather
; than of the display, so there is no register to ask and nothing to ask it of.
;
; This said forty first, and filled half of an eighty column screen. Then it asked the
; screen how wide it was, which fixed what could be seen and was still wrong: scrolling
; sideways walked off the 80 filled columns into the 48 that were not, and the grid went
; blank for six seconds before coming round again.
;
; ASKING THE SCREEN IS RIGHT FOR FILLING A SCREEN AND WRONG FOR FILLING A MAP. A program
; that writes one screenful wants the window; a program that scrolls wants everything the
; window can be moved over.
INIA 0d128
SETD.0 RowCells
STA.0
everyCell:
INIA 0d200
OUTA 0xE9
SETD.0 RowAttribute
LDA.0
OUTA 0xE9
SETD.0 RowCells
LDA.0
DECA
STA.0
BNA everyCell
SETD.0 MapRow
LDA.0
INCA
STA.0
INIB 0x80
CCF
SUB
BNQ everyRow
RET
#Data
#Base 0x3000
ScreenKept:
0x00
FineDown:
0x00
FineAcross:
0x00
OriginDown:
0x00
OriginAcross:
0x00
Scheme:
0x00
Spare:
0x00
LineRed:
0x00
LineGreen:
0x00
MapRow:
0x00
RowAttribute:
0x00
RowCells:
0x00
TileArtAt:
0x00 0x00
; ---- Eight by eight, a byte a pixel ----
;
; A line along the top and one down the left. Tiled edge to edge they meet, so a screenful
; of this one tile is a continuous grid rather than 1,000 separate boxes.
TileArt:
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
+7 -4
View File
@@ -17,7 +17,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
CIF ; Nothing arrives until there is something to catch it. CIF ; Nothing arrives until there is something to catch it.
@@ -39,14 +39,16 @@ start:
OUTA 0x02 OUTA 0x02
SIF SIF
wait: ; Called spin rather than wait because WAIT is an instruction now, and a label may not
; be one. Which is the joke of it: this loop is exactly what WAIT exists to replace.
spin:
; This loop is the point. It never touches the console, so every character that appears ; This loop is the point. It never touches the console, so every character that appears
; below was put there by something that interrupted it. ; below was put there by something that interrupted it.
SETD.3 Stopping SETD.3 Stopping
LDA.3 LDA.3
RSTB RSTB
OR OR
BRQ wait BRQ spin
CALL newLine CALL newLine
RSTA RSTA
@@ -54,6 +56,7 @@ wait:
SETD.0 DoneText SETD.0 DoneText
CALL printString CALL printString
CALL newLine CALL newLine
RSTA
SWI osExit SWI osExit
; Entered because the console had something to say. Never called. ; Entered because the console had something to say. Never called.
@@ -82,7 +85,7 @@ keyStop:
#Data #Data
#Base 0x1000 #Base 0x3000
Banner: Banner:
"keys, by interrupt. q stops." "keys, by interrupt. q stops."
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -32,12 +32,12 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
CALL seedGlider CALL seedGlider
SETD.0 ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
SETD.3 GenerationsLeft SETD.3 GenerationsLeft
INIA 0xFF INIA 0xFF
STA.3 STA.3
@@ -105,6 +105,7 @@ lifeEnd:
CALL lineFeed CALL lineFeed
CALL printString ; DP0 still holds the words: CALL puts DP0 back. CALL printString ; DP0 still holds the words: CALL puts DP0 back.
CALL lineFeed CALL lineFeed
RSTA
SWI osExit SWI osExit
seedGlider: seedGlider:
@@ -125,8 +126,9 @@ seedGlider:
RET RET
renderBoard: renderBoard:
SETD.0 CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD.1 RowCount SETD.1 RowCount
SETD.2 ColCount SETD.2 ColCount
INIA 0d16 INIA 0d16
@@ -348,7 +350,7 @@ delayDone:
#Data #Data
#Base 0x1000 #Base 0x3000
RowCount: RowCount:
0x00 0x00
@@ -370,12 +372,6 @@ RanOutText:
StoppedText: StoppedText:
"stopped" "stopped"
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The ; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The
; original leaves this implicit and leans on Data Memory being zero, which works but ; original leaves this implicit and leans on Data Memory being zero, which works but
+50
View File
@@ -0,0 +1,50 @@
; Forty columns or eighty, whichever the screen is not in.
;
; A machine wakes up in the forty column mode and CosmOS asks for eighty, because that is
; what its own output was written for. A game is the other way round: Snake on a forty column
; screen is the same board drawn twice the size, which is what a person sitting in front of
; it actually wants.
;
; ---- The smallest program this system can load ----
;
; Ten instructions and NO DATA AT ALL, which is not a curiosity: it is the shape that found
; a bug in the loader. Every program written for CosmOS until this one had something in its
; Data Segment, so the loader had never been asked to move a segment of no bytes - and a
; length of zero asks the memory controller for the whole 64K, which does not fit, which it
; refused, which stopped the machine in the middle of loading. On a terminal that printed a
; fault. Behind a window it looked exactly like a hang.
;
; So this is kept dataless on purpose. The two digits it prints are put in a register one at
; a time rather than being a string, which is the only reason it can say anything at all.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
INA 0x31 ; Which mode the screen is in now.
BRA modeWide ; Nought is the forty column one, so go the other way.
; Anything else becomes forty, and that deliberately includes bitmap mode. A program that
; left the screen with no text on it left nowhere to print, so coming back to a mode that
; has characters in it is more use than refusing.
RSTA
OUTA 0x31
INIA 0x34 ; '4'
BRI modeSay
modeWide:
INIA 0x01
OUTA 0x31
INIA 0x38 ; '8'
modeSay:
OUTA 0x00
INIA 0x30 ; '0'
OUTA 0x00
INIA 0x0A
OUTA 0x00
RSTA
SWI osExit
+193
View File
@@ -0,0 +1,193 @@
; Read a text file one screen at a time.
;
; A screenful of lines is shown before a prompt - as many as the screen has, asked for rather
; than assumed, so that the eighty column mode is not read three fifths empty. Space advances
; another screen, Return one line, and q gives the machine back to CosmOS. This is forward-only on purpose: the file
; stream holds one block and never asks the whole document to fit in memory.
;
; Written by ChatGPT for Anachronaut's SplitBit
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Name
INIB 0d63
SWI osArgument
SETD.0 Name
LDA.0
BRA noName
CALL fileStreamOpen
BNQ openFailed
CALL fullPage
nextBlock:
CALL fileStreamNext
BNQ readFailed
PSHD.3
POPB
POPA
SETD.2 Remaining
STA.2
INCD.2
STB.2
OR
BRQ finished
SETD.1 FileStreamBlock
SETD.2 Remaining
printLoop:
LDA.1
OUTA 0x00
INIB 0x0A
XOR
BNQ bytePrinted
SETD.3 LinesLeft
LDA.3
DECA
STA.3
BNA bytePrinted
CALL pause
BNQ finished
bytePrinted:
INCD.1
CALL fileStreamTakeRemaining
BRQ nextBlock
BRI printLoop
; Ask for one key without asking for Return. The console is put back immediately,
; including on q and end of redirected input.
pause:
SETD.0 MorePrompt
SWI osPrintString
INIA 0x01
OUTA 0x02
INA 0x00
PSHA ; Keep the key while A is used to restore line mode.
RSTA
OUTA 0x02
POPA
SETD.0 ClearPrompt
SWI osPrintString
INIB 0x71 ; q
XOR
BRQ pauseQuit
INIB 0xFF ; end of redirected input
XOR
BRQ pauseQuit
INIB 0x0A ; Return: one more line.
XOR
BRQ oneLine
INIB 0x20 ; Space: one more screen.
XOR
BNQ pause ; Ignore every other key.
CALL fullPage
RSTA
RSTB
OR
RET
oneLine:
SETD.3 LinesLeft
INIA 0x01
STA.3
RSTA
RSTB
OR
RET
pauseQuit:
RSTA
INIB 0x01
OR
RET
; ---- As many lines as the screen has, less the prompt ----
;
; Twenty two was written when there was one screen size. It is still right on the forty
; column screen and wastes half of the eighty column one, so this ASKS: the rows register
; says how tall the screen is, and it 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 who was already reading files this way - it fills a bigger screen and leaves a
; smaller one alone.
;
; A BITMAP SCREEN HAS NO ROWS AT ALL and says so with a nought, which would come out as 253
; lines through an eight bit subtraction. Anything under five falls back, because a page of
; two lines is not a page and a program should not be the thing that discovers this.
fullPage:
INA 0x33
SETD.3 LinesLeft
STA.3 ; Kept, because the sums below want A for themselves.
INIB 0d5
CCF
SUB
BRC fullPageFallback ; Borrowed, so there are fewer than five rows.
LDA.3
INIB 0d3
CCF
SUB
MVQA
STA.3
RET
fullPageFallback:
INIA 0d22
STA.3 ; DP3 is still LinesLeft, from above.
RET
noName:
SETD.0 Usage
SWI osPrintString
INIA 0d2
SWI osExit
; ---- What went wrong, in words ----
;
; It used to print the number the filesystem answered with, as "error 2". THERE IS NO SUCH
; VOCABULARY: the library documents its answer as zero or not zero and never as a code, so
; the number named nothing and could not be looked up - it just looked like it could.
;
; A name that is not on the disk is the only way opening fails that a person can do anything
; about, and it is nearly always a name typed slightly wrong. Saying so is more use than any
; number would have been.
openFailed:
SETD.0 OpenError
SWI osPrintString
BRI failed
readFailed:
SETD.0 ReadError
SWI osPrintString
failed:
; ITS OWN EXIT, and it did not have one. This fell through into finished and reported
; that everything was fine, having just printed the reason it was not - which nobody
; noticed while the only reader was a person, who could see both.
INIA 0d1
SWI osExit
finished:
RSTA
SWI osExit
#Data
#Base 0x3000
Usage:
"more: give me a file name
"
OpenError:
"more: there is no file by that name
"
ReadError:
"more: the disk would not give me that file
"
NewLine:
0x0A 0x00
MorePrompt:
"-- more --"
ClearPrompt:
0x0A 0x00
Name:
#Reserve 0d64
Remaining:
0x00 0x00
LinesLeft:
0x00
#Include fileStream.asm
+138
View File
@@ -0,0 +1,138 @@
; Once.asm
; Starts something else next time, and only next time.
;
; > Once /System/Boot/mine.bin
; next start: /System/Boot/mine.bin, once
; > reboot
;
; Writes /System/Boot/once.cfg, which the loader reads before boot.cfg and DELETES BEFORE
; IT JUMPS. So the image runs on the next start and on no other, whatever happens to it -
; a one shot that hangs cannot hang twice, because the request is gone before it ran.
;
; ---- What this is for ----
;
; A program that owns the whole machine has nowhere to run. It cannot be started from the
; shell, because starting it means there is no shell; and pointing boot.cfg at it means a
; machine that keeps starting it, which is a poor place to find a mistake. This is the
; missing step: write it, ask for it once, and the system comes back by itself.
;
; The file is the same format as boot.cfg because a second format for one setting would be
; a second format. It says `system` for the same reason.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Wanted
INIB 0d128
SWI osArgument
MVQA
BNA noName
SETD.0 Wanted
LDA.0
BRA noName
; The line, built as "system " and then the name. One write, because the file is the
; whole of the request and half of it would be a request for half a thing.
SETD.0 Prefix
SETD.1 Line
RCAL copyString
SETD.0 Wanted
RCAL copyString
INIA 0x0A
STA.1
INCD.1
RSTA
STA.1
; How long it came to, which is what the write is told.
SETD.0 Line
RCAL measure
SETD.0 OncePath
SETD.1 Line
SWI osFileSave
MVQA
BNA noWrite
SETD.0 DoneText
SWI osPrintString
SETD.0 Wanted
SWI osPrintString
SETD.0 OnceText
SWI osPrintString
RSTA
SWI osExit
noName:
SETD.0 Usage
SWI osPrintString
INIA 0d2
SWI osExit
noWrite:
SETD.0 NoWriteText
SWI osPrintString
INIA 0d1
SWI osExit
; DP0 names a string and DP1 where it goes. DP1 is left on the zero at the end, so one
; string can be written straight after another.
copyString:
LDA.0
BRA copyDone
STA.1
INCD.0
INCD.1
BRI copyString
copyDone:
RRET
; DP0 names the line. osFileSave wants a size, and a file of whole blocks and a tail is
; that count with the blocks in A and the tail in B - one block is never full here.
measure:
RSTB
measureLoop:
LDA.0
BRA measureDone
INCD.0
MVQB
INIA 0d1
CCF
ADD
MVQB
BRI measureLoop
measureDone:
RSTA
RRET
#Data
#Base 0x3000
Prefix:
"system "
Usage:
"once what? try: Once /System/Boot/something.bin
"
DoneText:
"next start: "
OnceText:
", once
"
NoWriteText:
"it would not write
"
OncePath:
"/System/Boot/once.cfg"
Wanted:
#Reserve 0d129
Line:
#Reserve 0d160
+162
View File
@@ -0,0 +1,162 @@
; What the controllers are doing, as they do it.
;
; Prints a line whenever any pad changes: which pad, and the byte it is now reporting. That is
; the whole of the diagnostic, and it is enough to tell apart the three things that look
; identical from inside a game which is not responding.
;
; Nothing printed at all, and nought pads present: the front end never saw a controller.
; A pad present but no lines: it is seen, and the buttons are mapped to nothing.
; Lines that do not match the buttons pressed: the mapping is wrong rather than missing.
;
; ---- Why a program rather than a print in the front end ----
;
; Because the question is what the MACHINE can see. A front end that printed what it thought
; it was sending would answer a different question, and the gap between those two is exactly
; where a controller that was detected, mapped and reported still did nothing: the port that
; says which pads exist counted only recorded ones, so a game asked whether there was a
; controller and was told no while the buttons underneath worked perfectly.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Intro
SWI osPrintString
; How many are there, before anybody presses anything. One bit a pad.
SETD.0 PresentText
SWI osPrintString
INA 0x64
CALL sayByte
INIA 0x0A
OUTA 0x00
INIA 0x01
OUTA 0x02 ; Key mode, so q arrives without a Return.
everyFrame:
INA 0x30
INIB 0x01
AND
BRQ everyFrame ; A frame, which is when a pad's recording steps.
RSTA
SETD.2 Which
STA.2
eachPad:
CALL onePad
SETD.2 Which
LDA.2
INCA
STA.2
INIB 0d4
CCF
SUB
BNQ eachPad
; q gives the machine back. Anything else typed is ignored, because a pad test that
; stopped on a stray keypress would be a poor thing to lean on.
INA 0x01
INIB 0x01
AND
BRQ everyFrame
INA 0x00
INIB 0x71 ; q
CCF
SUB
BNQ everyFrame
RSTA
OUTA 0x02
SWI osExit
; ---- One pad, printed only when it changes ----
;
; A line a frame for four pads would be two hundred and forty lines a second and unreadable.
; What is worth seeing is the moment something goes down or comes up.
onePad:
; The port is 0x60 plus the pad number, and a port is an immediate byte inside the
; instruction that names it - so it cannot be computed, and the four are written out.
SETD.2 Which
LDA.2
BRA padZero
DECA
BRA padOne
DECA
BRA padTwo
INA 0x63
BRI padGot
padZero:
INA 0x60
BRI padGot
padOne:
INA 0x61
BRI padGot
padTwo:
INA 0x62
padGot:
; Against what it was last time. Last is four bytes, one a pad, so DP1 walks to this one.
SETD.1 Last
PSHA
SETD.2 Which
LDA.2
DPUA.1
POPA
LDB.1
CCF
SUB
BRQ padSame ; The same as last frame, so there is nothing to say.
STA.1 ; Remembered, so the next frame has something to compare with.
PSHA
LDA.2 ; DP2 is still Which, from working out where in Last to look.
INIB 0x30 ; '0'
CCF
ADD
OUTQ 0x00
INIA 0x3A ; ':'
OUTA 0x00
INIA 0x20
OUTA 0x00
POPA
CALL sayByte
INIA 0x0A
OUTA 0x00
padSame:
RET
; ---- One byte, as a number ----
;
; osPrintNumber takes A and B TOGETHER, A being the high half - which is the same way round as
; the shift register and every other pair on this machine, and is not what a byte in A wants.
; Passed as it stood, every value came out two hundred and fifty six times too big.
sayByte:
RSTB
CCF
ADD
MVQB ; The byte, in the low half where it belongs.
RSTA ; And nothing in the high one.
SWI osPrintNumber
RET
#Data
#Base 0x3000
Intro:
"Press buttons on a controller. q quits.
"
PresentText:
"Pads present, as a bit each: "
; One byte a pad, holding what it said last frame.
Last:
0x00 0x00 0x00 0x00
Which:
0x00
+411
View File
@@ -0,0 +1,411 @@
; Play.asm
; Four voices on one clock, which is what music is and one channel cannot be.
; Written by Anachronaut
;
; ---- One tick, four cursors ----
;
; The sound device has four channels and no idea when. The timer has a period and no idea
; what. This is the thing between them: on every tick it walks four voices, and each voice
; counts down the note it is holding and reads the next one when the count runs out.
;
; THE VOICES SHARE NOTHING BUT THE TICK. Each keeps its own place in its own track and its
; own count of how much longer the current note lasts, so a voice playing whole notes and a
; voice playing eighths cost the same and never have to know about each other. That is why
; the tick is the smallest subdivision in the piece rather than a note length: it is the
; only unit all four can agree on.
;
; ---- What a sequence is ----
;
; Pairs of bytes: what to play, then how many ticks it lasts.
;
; 0x01 to 0x7F a MIDI note. 60 is middle C and every 12 is an octave.
; 0x00 a rest - the ticks pass with nothing sounding
; 0x80 to 0xFE a command, which takes no time at all. See below.
; 0xFF the sequence is over
;
; MIDI NOTES ONLY REACH 127, so the top of the byte was free and none of that had to be
; invented. Examples/tune.asm spent zero on its end marker and so had no way to write a rest
; at all, which one voice can just about live with and four cannot: voices do not all play at
; once, and the silences are what make them separate parts rather than a chord.
;
; A note's duration is its whole life. The gate goes down when the count runs out and the next
; event begins on the same tick, so a gap between two notes is WRITTEN, as a rest, rather than
; invented by the player out of some fraction it decided on.
;
; ---- Commands, of which there is one ----
;
; A command consumes no tick: the reader acts on it and reads the next event straight away, so
; commands sit BETWEEN notes in time rather than needing a place of their own.
;
; 0x80 <address> play the rest of this voice on that patch.
;
; A patch change reshapes whatever is still ringing on the voice, and nothing can be done
; about that here: a channel has one set of parameters and a note in its release is using
; them. Deferring the load to the next note would reshape the same tail. So it is a fact about
; the hardware rather than a choice about the format, and the cure is a rest long enough for
; the release, which is the composer's to write.
;
; ONE COMMAND, and the other 125 values left alone. A tempo change, a volume ramp and a note
; slide are all easy to add and impossible to remove, and no piece of music has asked for one.
;
; ---- Sequences, and the order they go in ----
;
; A sequence is one voice's phrase. Each voice has an ORDER LIST of its own - a table of
; sequence addresses, ending in a zero - and when a sequence runs out the voice takes the next
; address from it. The voice stops when the list does.
;
; THAT IS WHERE REPETITION COMES FROM, and it costs no notation: the bass below plays the same
; sequence in the first bar and the last, and it is written once. A thirty-two bar piece that
; reuses four phrases is four phrases and a list.
;
; PER VOICE RATHER THAN ONE SHARED LIST, because that is the shape this machine likes. A
; voice's order cursor is a pointer it advances by itself, which is the same LDD and STD move
; everything else here makes; a shared table of four-column rows would have every voice
; indexing into one place, which is a worse fit for a machine with four data pointers. The
; four columns are how it READS, and how a tracker would show it; they are not how it is
; stored. Nothing keeps the voices together except that their sequences add up to the same
; length, which is a thing a compiler can check and a hand-written tune has to get right.
;
; A voice silent through a bar has a sequence that rests for it. There is no need for a way to
; say "nothing here" when a rest already says it.
;
; ---- Where the state lives ----
;
; Six bytes a voice: two of order cursor, two of sequence cursor, one of count, one of live.
; Both cursors are two bytes and at the front because that is what LDD and STD move - a
; pointer through a pointer, which is the whole reason this is a loop over four voices instead
; of the same code written out four times.
;
; A voice starts pointed at Empty, which is a sequence of nothing but its end marker. The
; first tick runs its count out, finds the end, and goes to the order list for the real first
; sequence - so the beginning needs no special case at all.
#Include services.asm
#Program
#Base 0x5000
start:
; ---- An instrument for each voice ----
;
; Four patches, designed in soundThing and converted by SoundPatch, which is the only thing
; here that understands what a patch means. The player reads a count and that many parameter
; and value pairs and knows nothing else about them.
;
; WHICH PATCH EACH VOICE STARTS ON IS DECLARED, in VoiceStart below, rather than being four
; calls in a row here. That is the shape a tune read from a file will have - a starting
; instrument is state and belongs in a header - and saying it once means the two forms do
; not drift.
;
; A PATCH BELONGS TO ITS CHANNEL, which is why four of them can be up at once. It did not
; used to: the LFOs belonged to the whole device, so whichever patch was loaded last owned
; them for every voice. Kalimba has LFO 0 switched off and the other three have it on, so
; under the old device this piece would have played with the arpeggio's setting on all four
; parts - which is exactly the fault that made Lunar Porter's low fuel warning stop
; trilling after the first landing of a run.
; ---- A tune named on the line, or the one in here ----
;
; Nothing after the name plays what this program was built with. A name reads a file and
; plays that instead, which is what makes it a player rather than a program with one song.
SETD.0 TuneName
INIB 0d63
SWI osArgument
SETD.0 TuneName
LDA.0
BRA playBuiltIn ; Nothing was typed after the name.
SETD.1 TuneBuffer ; DP0 is still the name, which is what osFileRead wants.
SWI osFileRead
BNQ playNoFile
SETD.0 TuneBuffer
CALL useTune
BNQ playNotATune
BRI playReady
playBuiltIn:
CALL useBuiltIn
playReady:
INIA 0x60 ; Room over the top for four voices at once.
OUTA 0x46
; ---- The beat ----
;
; ONLY THE CONTROL BYTE. The period was set by whoever supplied the tune, because a tick
; belongs to the tune and not to this program: a file says what it wants in its header.
;
; It used to be written here as well, AFTER useTune had taken it out of the file - so every
; tune played at a sixteenth note at 120 beats a minute whatever its header said. The only
; reason nothing noticed is that the one fixture in the suite asks for exactly that.
;
; Writing control with the run bit set is what loads the period, which is why this comes
; after whoever set it and not before.
INIA 0x07
OUTA 0x51 ; Run, repeat, interrupt.
SIF
; ---- The loop ----
;
; Step every voice, see whether any of them is still going, and then sleep. Stepping before
; waiting is what makes the first note sound on the first tick rather than the second.
tickLoop:
SETD.1 Voice0
RSTA
CALL stepVoice
SETD.1 Voice1
INIA 0d1
CALL stepVoice
SETD.1 Voice2
INIA 0d2
CALL stepVoice
SETD.1 Voice3
INIA 0d3
CALL stepVoice
SETD.1 Playing
LDA.1
BRA finished
WAIT ; Nothing at all until the timer says a tick has gone by.
BRI tickLoop
finished:
; ---- Let the last note finish, and MEASURE how long that takes ----
;
; The gates all went down on the tick the tracks ended, but a gate down is a note released
; rather than a note stopped: the oboe and the strings have a release to run. Rendered and
; measured, the tail dies out eight tenths of a second after the last event, and eight ticks
; of ring is one second - which sounds like enough and is two tenths short of it.
;
; A program that exits with sound still in the air leaves nothing able to end it: the
; program is gone and cannot drop a gate. What that turns into depends on the front end -
; behind a window the tail simply finishes, and on a terminal EMULATED TIME STOPS while the
; machine blocks on a key, so the tail freezes and comes out a snippet per keystroke.
;
; Twelve ticks is a second and a half, which covers the measured tail with room over. It is
; a number about THESE FOUR INSTRUMENTS, and a patch with a longer release would want more -
; the general answer is for the system to quieten the device when a program stops, the way
; it puts the screen back, and that is not built.
INIB 0d12
lastRing:
WAIT
DECB
BNB lastRing
CIF
RSTA
OUTA 0x51
SWI osExit
; ---- What it says when it cannot ----
;
; Before the timer has been started, both of them, so there is nothing to stop and no handler
; to take away.
playNoFile:
SETD.0 NoFileSaid
SWI osPrintString
INIA 0x01
SWI osExit
playNotATune:
SETD.0 NotATuneSaid
SWI osPrintString
INIA 0x01
SWI osExit
; ---- The tune that is in this program ----
;
; What useTune does for a file, said in eight lines for a tune that is already in memory: the
; three tables the player asks for, and an order list for each voice. Nothing is relocated,
; because the assembler already wrote the addresses.
useBuiltIn:
; The tick this tune wants. For a file it comes out of the header; for this one it is here,
; so that both hand the player the same things.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54 ; 0x01E848 is 125,000: a sixteenth note at 120.
SETD.0 BuiltInPatches
SETD.1 PatchTable
STD.0.1
SETD.0 BuiltInSequences
SETD.1 SequenceTable
STD.0.1
SETD.0 BuiltInVoiceStart
SETD.1 VoiceStartAt
STD.0.1
SETD.0 Order0
SETD.1 Voice0
STD.0.1
SETD.0 Order1
SETD.1 Voice1
STD.0.1
SETD.0 Order2
SETD.1 Voice2
STD.0.1
SETD.0 Order3
SETD.1 Voice3
STD.0.1
CALL startVoices
RET
; The tick has nothing to do: the loop above is the player and WAIT only needs something to
; have happened. A handler still has to exist, because an interrupt with nothing installed to
; catch it is a fault. Taking it is what brings the line down - a program that POLLED the
; timer would have to read 0x50 instead.
tick:
RETI
#Data
#Base 0x3000
; The name that followed "Play", and room for the tune it names. WHOLE BLOCKS: osFileRead
; puts 256 bytes down whatever the file's length, so the room here is a multiple of that and
; not a guess at how big a tune is.
TuneName:
#Reserve 0d64
TuneBuffer:
#Reserve 0d1024
NoFileSaid:
"no such tune"
NotATuneSaid:
"that is not a tune"
; ---- The order lists ----
;
; A table of sequence addresses each, ending in a zero. Read the four of them across and they
; are the bars; read one down and it is a part.
;
; bar 1 2 3 4
; melody Mel1 Mel2 Mel3 Mel4
; harmony Har1 Har2 Har3 Har4
; bass BassC BassF BassG BassC
; arp ArpC ArpF ArpG Silent
;
; THE BASS PLAYS BassC TWICE and it is written once. That is the whole return on having an
; order list, and it is worth noticing how little it cost: a table of labels the assembler
; fills in, and ten instructions in stepVoice.
Order0:
0d0 0d1 0d2 0d3 0xFF
Order1:
0d4 0d5 0d6 0d7 0xFF
Order2:
0d8 0d9 0d10 0d8 0xFF ; BassC twice, written once.
Order3:
0d11 0d12 0d13 0d14 0xFF
; ---- The tables, which are the only places an address lives ----
;
; Writing these out by hand is exactly the tedium a compiler exists to remove: every sequence
; has to be counted into its place above, and moving one means renumbering. That it is
; unpleasant is the point of noticing it here rather than after a tool has baked the shape in.
; The tables this tune uses. The player holds POINTERS to them rather than the tables, because
; a tune read from a file has its tables wherever the file was put - so a built-in one hands
; over the same three addresses a loaded one would.
BuiltInPatches:
OboePatch StringsPatch SquarePatch KalimbaPatch
BuiltInSequences:
Mel1 Mel2 Mel3 Mel4 ; 0 to 3
Har1 Har2 Har3 Har4 ; 4 to 7
BassC BassF BassG ; 8 to 10
ArpC ArpF ArpG Silent ; 11 to 14
; The patch each voice starts on. Not assumed, and not four calls in a row: a starting
; instrument is state, and state belongs somewhere it can be read.
BuiltInVoiceStart:
0d0 0d1 0d2 0d3
; ---- Four bars of C, F, G, C ----
;
; A bar is sixteen ticks, and every sequence in a column adds up to sixteen. Nothing enforces
; that here: get one wrong and the voices come apart, quietly, some bars later. It is the
; first thing a compiler should check.
; The melody, in quarters and halves.
Mel1:
0d64 0d4 ; E
0d67 0d4 ; G
0d72 0d8 ; C, held
0xFF
Mel2:
0d65 0d4 ; F
0d69 0d4 ; A
0d72 0d8 ; C
0xFF
Mel3:
0d62 0d4 ; D
0d67 0d4 ; G
0d71 0d8 ; B
0xFF
Mel4:
0x80 0d1 ; Patch one is Strings: the last bar is a swell, not a reed.
0d72 0d16 ; and home
0xFF
; A second part underneath it, in halves.
Har1:
0d60 0d8 ; C
0d64 0d8 ; E
0xFF
Har2:
0d57 0d8 ; A
0d60 0d8 ; C
0xFF
Har3:
0d59 0d8 ; B
0d62 0d8 ; D
0xFF
Har4:
0d64 0d16 ; E
0xFF
; The bass, one note to a bar - and the first bar's is the last bar's.
BassC:
0d48 0d16 ; C
0xFF
BassF:
0d41 0d16 ; F
0xFF
BassG:
0d43 0d16 ; G
0xFF
; And an arpeggio in eighths, which is the part that proves the others are not waiting for it.
ArpC:
0d72 0d2 0d76 0d2 0d79 0d2 0d76 0d2
0d72 0d2 0d76 0d2 0d79 0d2 0d76 0d2
0xFF
ArpF:
0d77 0d2 0d81 0d2 0d84 0d2 0d81 0d2
0d77 0d2 0d81 0d2 0d84 0d2 0d81 0d2
0xFF
ArpG:
0d79 0d2 0d83 0d2 0d86 0d2 0d83 0d2
0d79 0d2 0d83 0d2 0d86 0d2 0d83 0d2
0xFF
Silent:
0d00 0d16 ; Out for the last bar, so it is the other three.
0xFF
; The scheduler, the patch loader and the state a voice keeps. The tune above is this
; program's; everything that plays it is shared with whatever else wants to.
#Include player.asm
; The instruments, at the bottom because each brings its own #Data and a base has to come
; before anything is in the segment it bases.
#Include oboe.asm
#Include strings.asm
#Include square.asm
#Include kalimba.asm
#Vectors
Boot start
Device 0x50 tick
+245
View File
@@ -0,0 +1,245 @@
; Writes a file one block at a time, without ever holding the whole of it.
;
; This is the write side of what Stream demonstrates for reading: a file bigger than the
; memory building it. It writes as many blocks as it is asked for, each one filled with a
; pattern that says which block it is, so that what comes off the disk afterwards can be
; checked against what should have gone on rather than merely being the right length.
;
; The argument is how many blocks, in decimal. The last one is deliberately a part block,
; because a tail is the case every off-by-one in a filesystem hides in.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Argument
INIB 0d8
SWI osArgument
SETD.0 Argument
LDA.0
BRA useDefault
CALL readCount
BRI counted
useDefault:
INIA 0d4
SETD.0 Blocks
STA.0
counted:
; Nothing may be zero blocks: the tail below would then be the whole file.
SETD.0 Blocks
LDA.0
BNA haveCount
INIA 0d1
STA.0
haveCount:
; Whole blocks, and a tail of forty bytes on the end of them.
SETD.0 Name
SETD.3 0x00 0x00
SETD.0 Blocks
LDA.0
PSHA
POPB
RSTA
PSHA
PSHB
POPD.3
SETD.0 Name
INIA 0d40
SWI osFileStart
BNQ startFailed
; Each whole block, filled with its own number.
RSTA
SETD.0 Which
STA.0
nextBlock:
SETD.0 Which
LDA.0
SETD.2 Blocks
LDB.2
CCF
SUB
BRQ theTail
CALL fillBlock
SETD.1 Block
SETD.0 Which
LDA.0
RSTB
PSHA
POPB
RSTA
SWI osFileWrite
BNQ writeFailed
SETD.0 Which
LDA.0
INCA
STA.0
BRI nextBlock
theTail:
; And the part block at the end, which is the same fill cut short by the size given at
; the start. Only the first forty bytes of it will belong to the file.
CALL fillBlock
SETD.1 Block
SETD.0 Which
LDA.0
RSTB
PSHA
POPB
RSTA
SWI osFileWrite
BNQ writeFailed
; And how big it turned out to be, which here is what was asked for: this one knows its
; size from the start. Something that did not - an assembler, say - would ask for more
; than it needed and say the truth here.
RSTA
PSHA
SETD.0 Blocks
LDA.0
PSHA
POPD.3
INIA 0d40
SWI osFileDone
BNQ doneFailed
SETD.0 Wrote
SWI osPrintString
SETD.0 Blocks
LDB.0
RSTA ; A and B together are the number, so the count is the low half.
SWI osPrintNumber
SETD.0 AndTail
SWI osPrintString
RSTA
SWI osExit
; The block becomes 256 copies of the block number plus a fixed byte, so that a block
; written into the wrong place is visible rather than merely being bytes.
fillBlock:
SETD.0 Block
SETD.1 Which
LDA.1
INIB 0x41
CCF
ADD
MVQA
RSTB
fillLoop:
STA.0
INCD.0
DECB
BNB fillLoop
RET
; The argument, in decimal, into Blocks. Anything that is not a digit ends it.
readCount:
RSTA
SETD.1 Blocks
STA.1
SETD.0 Argument
countLoop:
LDA.0
BRA countDone
INIB 0x30
CCF
SUB
MVQA
INIB 0d10
CCF
SUB
BNC countDone ; Not a digit, so the number ended.
SETD.1 Blocks
LDB.1
PSHA
INIA 0d10
CALL timesTen
POPA
SETD.1 Scratch
LDB.1
CCF
ADD
MVQA
SETD.1 Blocks
STA.1
INCD.0
BRI countLoop
countDone:
RET
; B times ten into Scratch, by adding it up. Nothing here is bigger than a byte.
timesTen:
RSTA
SETD.0 Scratch
STA.0
INIA 0d10
tenLoop:
PSHA
SETD.0 Scratch
LDA.0
SETD.2 TenHold
STB.2
LDB.2
CCF
ADD
MVQA
STA.0
POPA
DECA
BNA tenLoop
RET
startFailed:
SETD.0 NoStart
SWI osPrintString
INIA 0d1
SWI osExit
writeFailed:
SETD.0 NoWrite
SWI osPrintString
INIA 0d1
SWI osExit
doneFailed:
SETD.0 NoDone
SWI osPrintString
INIA 0d1
SWI osExit
#Data
#Base 0x3000
Name:
"poured.dat"
Argument:
#Reserve 0d9
Blocks:
0x00
Which:
0x00
Scratch:
0x00
TenHold:
0x00
Block:
#Reserve 0d256
Wrote:
"poured "
AndTail:
" blocks and a tail of 40
"
NoStart:
"could not start it
"
NoWrite:
"could not write a block
"
NoDone:
"could not finish it
"
+178
View File
@@ -0,0 +1,178 @@
; What the console just handed over, in hexadecimal and by name.
;
; The keys that are not characters - the arrows, Home, End and forward Delete - arrive as
; the console's own values above ASCII rather than as the escape sequences a terminal sends
; or as nothing at all, which is what a window used to make of them. This is what shows
; that, and it shows both halves of the rule in one run:
;
; A LINE FIRST, read the way everything reads one. Line mode delivers characters, so the
; keys are dropped before they reach the buffer and what comes back is what a person could
; have typed. Pressing Up while something else is collecting a line does nothing, which is
; an improvement on putting an escape and a bracket in the middle of it.
;
; THEN THE KEYS, in key mode, where a program has asked for every keystroke as it happens
; and these are keystrokes like any other.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 LineText
CALL printString
CALL newLine
SETD.0 Buffer
INIB 0d63
CALL readLine
SETD.0 Buffer
CALL showBytes
SETD.0 KeyText
CALL printString
CALL newLine
INIA 0x01
OUTA 0x02 ; Key mode. Nothing echoes, so everything below says what it saw.
keyLoop:
INA 0x00
INIB 0xFF
XOR
BRQ keyDone ; Nothing more is coming.
INIB 0x71 ; q, which is how this is stopped.
XOR
BRQ keyDone
CALL showKey
BRI keyLoop
keyDone:
RSTA
OUTA 0x02 ; Line mode, the way it was found.
SETD.0 DoneText
CALL printString
CALL newLine
RSTA
SWI osExit
; DP0 names a string of bytes ending in a zero. Prints each as two hexadecimal digits, so
; that what is in the buffer can be read rather than guessed at.
showBytes:
LDA.0
BRA showBytesDone
CALL printByteHex
INIA 0x20
OUTA 0x00
INCD.0
BRI showBytes
showBytesDone:
CALL newLine
RET
; A holds a key. Prints its value and then what it is.
;
; A survives a CALL, so the byte is still here after printing it - but only until something
; else is put in A, which the space below does. So it is kept where the naming can find it.
showKey:
SETD.1 KeyByte
STA.1
CALL printByteHex
INIA 0x20
OUTA 0x00
SETD.1 KeyByte
LDA.1
; XOR leaves the answer in Q and A alone, so one load stands for the whole ladder.
INIB 0x80
XOR
BRQ keyUp
INIB 0x81
XOR
BRQ keyDown
INIB 0x82
XOR
BRQ keyLeft
INIB 0x83
XOR
BRQ keyRight
INIB 0x84
XOR
BRQ keyHome
INIB 0x85
XOR
BRQ keyEnd
INIB 0x86
XOR
BRQ keyDelete
; An ordinary character, which is its own best name.
OUTA 0x00
CALL newLine
RET
keyUp:
SETD.0 UpText
BRI keySay
keyDown:
SETD.0 DownText
BRI keySay
keyLeft:
SETD.0 LeftText
BRI keySay
keyRight:
SETD.0 RightText
BRI keySay
keyHome:
SETD.0 HomeText
BRI keySay
keyEnd:
SETD.0 EndText
BRI keySay
keyDelete:
SETD.0 DeleteText
keySay:
CALL printString
CALL newLine
RET
#Data
#Base 0x3000
LineText:
"a line, then keys. q stops."
KeyText:
"keys:"
DoneText:
"done"
UpText:
"up"
DownText:
"down"
LeftText:
"left"
RightText:
"right"
HomeText:
"home"
EndText:
"end"
DeleteText:
"delete"
KeyByte:
0x00
Buffer:
#Reserve 0d64
#Vectors
Boot start
#Include console.asm
+52
View File
@@ -0,0 +1,52 @@
; Reboot.asm
; Starts the machine again.
;
; Whatever put the first instruction in memory does it again, and everything after that
; follows: the boot slot is read, the loader runs, and whatever the configuration names -
; or whatever Once asked for - is what starts.
;
; ---- Why this writes a port ----
;
; A reset has to work when the system does not. Something that could only be asked for
; through SWI would be unavailable in exactly the case that wants it most, and a program
; that owns the whole machine has no system to ask. So the machine takes it directly, the
; way it takes everything else: a byte out of a port.
;
; It is still a program rather than a shell word, because it is not the shell's business
; and because a word built into the shell is not callable by anything else.
;
; ---- What survives ----
;
; The disk, and everything written to it. That is what warm means: the machine starts
; again, the world it starts into does not. Nothing here flushes anything, because nothing
; on this machine is held back - a file is on the disk when the write returns.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Saying
SWI osPrintString
INIA 0x01
OUTA 0x13
; Not reached. The machine has started over by the time the next instruction would run,
; so anything here is a statement about a machine that no longer exists - but a program
; whose last instruction is an output is one byte from running into whatever follows it,
; and that is not a habit worth keeping.
RSTA
SWI osExit
#Data
#Base 0x3000
Saying:
"starting again
"
+3 -2
View File
@@ -16,7 +16,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
SETD.0 Given SETD.0 Given
@@ -40,11 +40,12 @@ sayNothing:
sayEnd: sayEnd:
SETD.0 NewLine SETD.0 NewLine
SWI osPrintString SWI osPrintString
RSTA
SWI osExit SWI osExit
#Data #Data
#Base 0x1000 #Base 0x3000
SaidText: SaidText:
"it says: " "it says: "
+93
View File
@@ -0,0 +1,93 @@
; Settle.asm
; Says how the last start went, and tells the machine to stop falling back.
;
; A program rather than a shell command, because the shell is for the things you cannot do
; without it and this is not one of them. It reaches the system through SWI like anything
; else, which means it can be replaced, left off a disk, or called by whatever comes to
; call programs in sequence - and none of that is true of a word built into the shell.
;
; ---- What settling means ----
;
; The loader marks the disk before handing over and the system clears the mark on reaching
; its prompt, so a mark still set is a start that never arrived. After that the loader uses
; the fallback and KEEPS USING IT, because a system known not to start should not be tried
; every other boot for ever.
;
; Settling is how it is told that has changed. It does not fix anything and it does not
; check anything: it says "the situation is different now, try again". Which is why it is a
; deliberate act by somebody who has just changed something, rather than anything automatic.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SWI osBootState
MVQA
SETD.0 State
STA.0
BRA alreadySettled
INIB 0d2
XOR
BRQ fellBack
; Trying. Nothing has gone wrong yet - this is what the disk looks like while a start is
; still in progress, which from in here means the system that is running has not reached
; its prompt, which it plainly has. So the mark is stale.
SETD.0 WasTrying
SWI osPrintString
BRI doSettle
fellBack:
SETD.0 WasFallen
SWI osPrintString
doSettle:
SWI osBootSettle
MVQA
BNA settleFailed
SETD.0 Settled
SWI osPrintString
RSTA
SWI osExit
settleFailed:
SETD.0 NoDisk
SWI osPrintString
INIA 0d1
SWI osExit
alreadySettled:
SETD.0 Already
SWI osPrintString
RSTA
SWI osExit
#Data
#Base 0x3000
WasTrying:
"the disk says a start is still in progress
"
WasFallen:
"the disk says the last start did not arrive, so this is the fallback
"
Settled:
"settled: the next start will use the configuration again
"
Already:
"already settled: the next start will use the configuration
"
NoDisk:
"nothing to settle: no disk answered
"
State:
0x00
+4 -3
View File
@@ -1,7 +1,7 @@
; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA. ; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA.
; ;
; This deliberately implements the same algorithm and emits the same text as ; This deliberately implements the same algorithm and emits the same text as
; 16bitSegmentedSieve.asm, making the two versions useful as a direct comparison. ; 16bitSieve.asm, making the two versions useful as a direct comparison.
; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3 ; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3
; marks multiples. CALL preserves the first three pointers automatically. ; marks multiples. CALL preserves the first three pointers automatically.
@@ -9,7 +9,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
RSTA RSTA
@@ -74,6 +74,7 @@ advancePage:
finished: finished:
CALL lineFeed CALL lineFeed
RSTA
SWI osExit ; Return to CosmOS. SWI osExit ; Return to CosmOS.
; DP0 points at a PrimeStates entry. CALL restores it on return. ; DP0 points at a PrimeStates entry. CALL restores it on return.
@@ -139,7 +140,7 @@ printCandidateHex:
#Data #Data
#Base 0x1000 #Base 0x3000
; Segment has to begin on a page boundary, and now says so itself rather than relying on ; Segment has to begin on a page boundary, and now says so itself rather than relying on
; whatever happens to have been assembled before it. The marking loop adds the prime to ; whatever happens to have been assembled before it. The marking loop adds the prime to
+3 -2
View File
@@ -4,7 +4,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; Search the list until we find a prime. ; Search the list until we find a prime.
@@ -38,6 +38,7 @@ start:
BRI markMultiples ; Otherwise, loop again to mark the next multiple as nonprime. BRI markMultiples ; Otherwise, loop again to mark the next multiple as nonprime.
end: end:
CALL lineFeed ; Print a linefeed to make it look nice. CALL lineFeed ; Print a linefeed to make it look nice.
RSTA
SWI osExit ; The program is done, we found all the primes! SWI osExit ; The program is done, we found all the primes!
@@ -45,7 +46,7 @@ start:
#Data #Data
#Base 0x1000 #Base 0x3000
; The table of our prime candidates. It has to begin on a page boundary: marking walks ; The table of our prime candidates. It has to begin on a page boundary: marking walks
; the pointer's low byte and treats the carry out as running off the end of the table, ; the pointer's low byte and treats the carry out as running off the end of the table,
+25 -21
View File
@@ -38,7 +38,7 @@
#Program #Program
#Base 0x2000 #Base 0x5000
start: start:
; The two pointers whose low byte is a square number. Both regions are page aligned, so ; The two pointers whose low byte is a square number. Both regions are page aligned, so
@@ -60,13 +60,17 @@ start:
CALL placeSnake CALL placeSnake
CALL placeFood CALL placeFood
SETD.0 ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
; Key mode, so that one key is one byte and arrives when it is pressed. It is put back ; Key mode, so that one key is one byte and arrives when it is pressed. It is put back
; before this returns, and CosmOS puts it back too in case a program stops without ; before this returns, and CosmOS puts it back too in case a program stops without
; doing so. ; doing so.
INIA 0x01 ;
; A is already 1 from the clear above, and leaving this out would save a byte by tying a
; console COMMAND to a console MODE that happens to share a number. That is a coincidence
; rather than a saving, and it would break silently if either ever moved.
INIA 0x01 ; splitlint[redundant-assignment]: see above
OUTA 0x02 OUTA 0x02
gameLoop: gameLoop:
@@ -105,6 +109,7 @@ gameOverSay:
CALL newLine CALL newLine
RSTA RSTA
OUTA 0x02 ; Line mode, the way it was found. OUTA 0x02 ; Line mode, the way it was found.
RSTA ; splitlint[redundant-assignment]: an exit status, not a console mode
SWI osExit SWI osExit
; ---- Reaching a square ---- ; ---- Reaching a square ----
@@ -334,7 +339,6 @@ takeKeyTurn:
XOR XOR
POPA POPA
BRQ takeKeyDone ; Opposite, so it is not a turn anybody can make. BRQ takeKeyDone ; Opposite, so it is not a turn anybody can make.
SETD.0 Direction
STA.0 STA.0
RET RET
@@ -430,7 +434,6 @@ stepDown:
INIB 0xF0 INIB 0xF0
AND AND
MVQA MVQA
INIB 0xF0
XOR XOR
POPA POPA
BRQ stepWall ; The bottom row is where the high nibble is fifteen. BRQ stepWall ; The bottom row is where the high nibble is fifteen.
@@ -453,7 +456,6 @@ stepRight:
INIB 0x0F INIB 0x0F
AND AND
MVQA MVQA
INIB 0x0F
XOR XOR
POPA POPA
BRQ stepWall BRQ stepWall
@@ -531,8 +533,9 @@ removeTail:
; The whole board, every frame, from the top left corner. Sixteen by sixteen is small ; The whole board, every frame, from the top left corner. Sixteen by sixteen is small
; enough that working out what changed would cost more than sending it all again. ; enough that working out what changed would cost more than sending it all again.
draw: draw:
SETD.0 CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD.0 BorderText SETD.0 BorderText
CALL printString CALL printString
CALL newLine CALL newLine
@@ -588,12 +591,19 @@ drawPut:
; ---- Waiting ---- ; ---- Waiting ----
; ;
; There is no clock on this machine, so time is counted in instructions. At the emulated ; There is no clock on this machine, so time is counted in cycles. At the emulated rate this
; rate this is about an eighth of a second, which is a speed a person can play at. Running ; is about an eighth of a second, which is a speed a person can play at. Running the emulator
; the emulator faster or slower moves it, and that is the honest answer: the machine has ; faster or slower moves it, and that is the honest answer: the machine has no way to know
; no way to know how long a second is and this program is not going to pretend it does. ; how long a second is and this program is not going to pretend it does.
;
; THE COUNT USED TO BE 256 AND THE COMMENT USED TO SAY INSTRUCTIONS. When a cycle stopped
; being an instruction and became a memory access, every loop in the machine got dearer and
; this one silently doubled - the game has been running at half the speed it says ever since,
; in a terminal as much as in a window. The inner loop is a DECA and a BNA, one byte and
; three, so four cycles a turn: 122 times 256 times 4 is about 125,000, which is an eighth of
; a second at a megahertz.
pause: pause:
RSTB INIB 0d122
pauseOuter: pauseOuter:
RSTA RSTA
pauseInner: pauseInner:
@@ -605,7 +615,7 @@ pauseInner:
#Data #Data
#Base 0x1000 #Base 0x3000
HeadCell: HeadCell:
0x00 0x00
@@ -644,12 +654,6 @@ RandomSeed:
RandomState: RandomState:
0x00 0x00 0x00 0x00
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
BorderText: BorderText:
"+----------------+" "+----------------+"
+247
View File
@@ -0,0 +1,247 @@
; A thing that moves without the screen moving.
;
; Everything drawn on this machine before sprites was in a CELL. Something between two cells
; meant rewriting both of them, and 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 already there. Moving
; it costs two bytes: the low and high halves of where it now is. That is the whole of the
; loop below, and it is why this program can run over the shell's own text without disturbing
; a single character of it - nothing underneath is written to at all.
;
; ---- What it leaves behind ----
;
; The sprite, still in the table. On purpose, and for the same reason Flip leaves the screen
; it flipped to: a program that FAULTED would have left it too, and a system that only tidied
; up after programs which remembered would be one that left a ball sitting over the prompt
; the first time somebody's game crashed. The table is the system's to clear.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; The atlas, which is where both the tiles and the sprite table live. Four is what CosmOS
; uses for it; see the table in the CosmOS README for who owns which number.
INIA 0d4
OUTA 0xE3
INIA 0x30
OUTA 0xE2
INIA 0x03
OUTA 0xE8
SETD.0 Message
SWI osPrintString
INIA 0x0A
OUTA 0x00
CALL putBall
CALL screenWidth
CALL putSprite
; Key mode, so a key arrives when it is pressed rather than when Return is.
INIA 0x01
OUTA 0x02
everyFrame:
CALL waitFrame
CALL stepBall
CALL moveSprite
; Anything typed ends it. Asked for and never waited on, so a key pressed between frames
; is still there when this looks.
INA 0x01
INIB 0x01 ; READY
AND
BRQ everyFrame
INA 0x00 ; Taken, so the shell is not handed a key meant for this.
RSTA
OUTA 0x02 ; Line mode again. The sprite is left where it is.
SWI osExit
; ---- The art, into a tile above the font ----
;
; Two hundred, which is well clear of the 135 glyphs the character generator copies back, so
; nothing here costs the shell a letter. Blitted rather than poked: it is already sixty four
; bytes of Data Segment and the controller moves it in one command.
putBall:
INIA 0x01
OUTA 0xE0 ; SourceBank: Data Memory.
; The pointer written down before it is read out of memory a byte at a time, because a Data
; Pointer's two halves cannot be got at any other way.
SETD.1 BallArtAt
SETD.0 BallArt
STD.0.1
LDA.1
OUTA 0xE1
INCD.1
LDA.1
OUTA 0xE2
INIA 0d4
OUTA 0xE3
INIA 0x32
OUTA 0xE4 ; Tile 200 begins at 200 times 64, which is 0x3200.
RSTA
OUTA 0xE5
OUTA 0xE6
INIA 0x40
OUTA 0xE7 ; Sixty four bytes.
INIA 0x01
OUTA 0xE8 ; Blit.
RET
; ---- How wide the screen is, in pixels ----
;
; Columns times eight, and this machine cannot multiply. A and B are one sixteen bit shift
; register though: with the column count in A and nothing in B, A:B holds columns times 256,
; and five shifts right divide that by thirty two - which is columns times eight, high byte
; left in A and low byte in B.
;
; Asked rather than assumed, because the shell runs eighty columns and a game may well have
; asked for forty before starting this.
screenWidth:
INA 0x32
RSTB
SHR
SHR
SHR
SHR
SHR
SETD.0 WidthHigh
STA.0
; B cannot be stored, and there is no move from it. Adding nothing to it puts it in Q,
; which can be copied to A, which can.
RSTA
CCF
ADD
MVQA
SETD.0 WidthLow
STA.0
RET
; ---- The entry, written straight through ----
;
; The controller's Data port steps its address on after every byte, so all eight go out of
; one port with the address named once.
putSprite:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
RSTA
OUTA 0xE5 ; Sprite nought is at 0xC000.
INIA 0xC8
OUTA 0xE9 ; Tile 200.
INIA 0x01
OUTA 0xE9 ; Attribute one, so the ball comes out in scheme one's ink.
RSTA
OUTA 0xE9
OUTA 0xE9 ; X, low then high.
INIA 0d96
OUTA 0xE9
RSTA
OUTA 0xE9 ; Y, ninety six pixels down.
INIA 0x11
OUTA 0xE9 ; One tile across by one down.
RSTA
OUTA 0xE9 ; Not mirrored, not turned over, not behind.
RET
; A frame, which is the only regular beat this machine has.
waitFrame:
INA 0x30
INIB 0x01
AND
BRQ waitFrame
RET
; ---- One pixel to the right ----
;
; Sixteen bits in two bytes, so the high one is stepped only when the low one came back round
; to nought - which is what a carry is, done by hand.
stepBall:
SETD.0 BallX
LDA.0
INCA
STA.0
BNA stepCheck
SETD.0 BallXHigh
LDA.0
INCA
STA.0
; Round to the left edge at the far side. Both halves have to match, and the high one is
; tested first because it is the one that is usually wrong.
stepCheck:
SETD.0 BallXHigh
LDA.0
SETD.1 WidthHigh
LDB.1
CCF
SUB
BNQ stepDone
SETD.0 BallX
LDA.0
SETD.1 WidthLow
LDB.1
CCF
SUB
BNQ stepDone
RSTA
STA.0 ; DP0 is still BallX, from the comparison just above.
SETD.0 BallXHigh
STA.0
stepDone:
RET
; Two bytes out of one port, which is the whole cost of moving a sprite.
moveSprite:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
INIA 0x02
OUTA 0xE5 ; X is bytes two and three of the entry.
SETD.0 BallX
LDA.0
OUTA 0xE9
SETD.0 BallXHigh
LDA.0
OUTA 0xE9
RET
#Data
#Base 0x3000
Message:
"A ball, over the shell's own words. Nothing underneath is written to. Press a key."
; Index nought is not a colour, it is a hole - so the corners of the tile are what the ball
; is not, and whatever is behind shows through them.
BallArt:
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
BallArtAt:
#Reserve 0d2
BallX:
0x00
BallXHigh:
0x00
WidthLow:
0x00
WidthHigh:
0x00
+87
View File
@@ -0,0 +1,87 @@
; Status.asm
; Says what the last program made of what it was asked to do.
;
; The shell keeps the number and does not print it, because a program that failed has
; already said so in words and a number beside that would be noise. But a number nobody can
; see is a number nobody can trust, so this is how a person looks.
;
; 0 it did what it was asked
; 1 it did not
; 2 it was asked wrongly
;
; A program may give its own meanings if it says so, and Compare does: one there means the
; files differ, which is a result rather than a failure.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SWI osLastStatus
MVQA
SETD.0 Was
STA.0
SETD.0 Prefix
SWI osPrintString
; A and B together, most significant first - so the status is the LOW half. Put in A the
; first time this was written, which printed a status of two as five hundred and twelve.
RSTA
SETD.0 Was
LDB.0
SWI osPrintNumber
; And in words, for the three the system itself uses.
SETD.0 Was
LDA.0
BRA sayWorked
INIB 0d1
XOR
BRQ sayFailed
SETD.0 Was
LDA.0
INIB 0d2
XOR
BRQ sayAsked
BRI done
sayWorked:
SETD.0 WorkedText
SWI osPrintString
BRI done
sayFailed:
SETD.0 FailedText
SWI osPrintString
BRI done
sayAsked:
SETD.0 AskedText
SWI osPrintString
done:
SETD.0 NewLine
SWI osPrintString
RSTA
SWI osExit
#Data
#Base 0x3000
Prefix:
"the last program left "
WorkedText:
", which is: it did what it was asked"
FailedText:
", which is: it did not"
AskedText:
", which is: it was asked wrongly"
NewLine:
"
"
Was:
0x00
+495
View File
@@ -0,0 +1,495 @@
; Reading a file the machine cannot hold.
;
; Every other program here asks for a file and is handed the whole of it, which settles the
; question for anything under 64K and settles nothing above. CosmOS's own source is above:
; the sources together are a hundred kilobytes, and Data Memory is sixty four. A machine
; that is one day going to assemble itself has to be able to read a file bigger than its
; memory, and this is the program that proves it can.
;
; It uses osFileInfo and osFileBlock, and nothing else knows how a filesystem works. There
; is no open and no close - every call names the file and says which block it wants, so a
; program that stops halfway leaves nothing behind for anybody to clean up.
;
; ---- What it checks, and why each one is here ----
;
; 1. A file of four hundred odd blocks is read from end to end, a block at a time, into a
; buffer of one block. That is the feature.
; 2. A small file is read BOTH WAYS - whole with osFileRead, and streamed - and the two
; have to agree. This is the real proof: it compares streaming against the path that
; was already known to work, so a fault in the block count or the order of the blocks
; shows up as a difference rather than as a plausible wrong answer.
; 3. Two files are read alternately. The system remembers where the last file it was
; asked about lives, and this is the case that catches a memory that does not notice
; the name has changed.
; 4. A rename in the middle. Same reason, from the other side: the file the system
; remembers has moved out from under the name it remembered it by.
; 5. The three ways of being told no, each with its own number.
;
; THE CHECKSUM IS FLETCHER'S, not a sum. A plain total is the same whatever order the bytes
; arrived in, and the order is exactly what streaming has to get right; carrying a second
; accumulator that adds the first one in each time makes a block delivered out of turn
; change the answer.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; ---- 1. How big is something that will not fit ----
;
; In blocks, not bytes, and that is forced rather than chosen: a file on a sixteen
; megabyte disk can be twenty four bits long and a pointer holds sixteen.
SETD.0 BigName
SWI osFileInfo
BNQ noBig
SETD.0 BigIs
SWI osPrintString
PSHD.3
POPB
POPA
SWI osPrintNumber
SETD.0 BlocksText
SWI osPrintString
; ---- 2. Read the whole of it through a hole one block wide ----
CALL clearChecksum
CALL clearIndex
bigLoop:
SETD.0 BigName
SETD.1 Block
SETD.2 Index
LDA.2
INCD.2
LDB.2 ; Which block, most significant first.
SWI osFileBlock
BNQ bigDone
CALL takeCount
SETD.1 Block
CALL checksum
CALL stepIndex
BRI bigLoop
bigDone:
; The loop ends because a block past the end was asked for, which is answer three. Any
; other answer stopped it early and would otherwise look exactly like success, so what
; ended it is printed rather than assumed.
CALL keepWhy
SETD.0 ReadText
SWI osPrintString
SETD.2 Index
LDA.2
INCD.2
LDB.2
SWI osPrintNumber
SETD.0 BlocksSumText
SWI osPrintString
CALL printChecksum
SETD.0 StoppedText
SWI osPrintString
CALL printWhy
; ---- 3. The same file both ways ----
;
; osFileRead is the path that already worked, so it is what streaming is measured
; against. If the two checksums agree, every byte arrived and they arrived in order.
SETD.0 SmallName
SETD.1 Whole
SWI osFileRead
BNQ noSmall
CALL takeCount
CALL clearChecksum
SETD.1 Whole
CALL checksum
CALL keepChecksum
CALL clearChecksum
CALL clearIndex
smallLoop:
SETD.0 SmallName
SETD.1 Block
SETD.2 Index
LDA.2
INCD.2
LDB.2
SWI osFileBlock
BNQ smallDone
CALL takeCount
SETD.1 Block
CALL checksum
CALL stepIndex
BRI smallLoop
smallDone:
SETD.0 BothText
SWI osPrintString
CALL printChecksum
SETD.0 AgainstText
SWI osPrintString
CALL printKept
SETD.0 NewLine
SWI osPrintString
CALL sameAsKept
BNQ differ
SETD.0 SameText
SWI osPrintString
BRI interleave
differ:
SETD.0 DifferText
SWI osPrintString
; ---- 4. Two files, alternately ----
;
; Block zero of the big file, then a block of the small one, then block zero of the big
; file again. The two readings of the same block have to match. A system that remembered
; the first file and did not notice the name had changed would hand back a block of the
; wrong file in the middle, and then the right one again, so only the middle call would
; be wrong - which is why this asks for the same block twice rather than once.
interleave:
CALL clearChecksum
CALL readFirstBig
CALL keepChecksum
SETD.0 SmallName
SETD.1 Block
RSTA
RSTB
SWI osFileBlock
CALL clearChecksum
CALL readFirstBig
CALL sameAsKept
BNQ mixedUp
SETD.0 InterleaveOk
SWI osPrintString
BRI moved
mixedUp:
SETD.0 InterleaveBad
SWI osPrintString
; ---- 5. A file that moves out from under the name ----
;
; The system has just been asked about the small file, so it is the one being remembered.
; Renaming it has to throw that away: the blocks are still there and still hold the same
; bytes, so a stale answer would work perfectly and be wrong.
moved:
SETD.0 SmallName
SETD.1 OtherName
SWI osFileRename
BNQ noRename
SETD.0 MovedText
SWI osPrintString
SETD.0 SmallName
SWI osFileInfo
CALL keepWhy
SETD.0 OldNameText
SWI osPrintString
CALL printWhy
SETD.0 NewNameText
SWI osPrintString
SETD.0 OtherName
SWI osFileInfo
CALL keepWhy
CALL printWhy
; ---- 6. The three ways of being told no ----
missing:
SETD.0 MissingName
SWI osFileInfo
CALL keepWhy
SETD.0 MissingText
SWI osPrintString
CALL printWhy
SETD.0 OtherName
SETD.1 Block
INIA 0xFF
INIB 0xFF
SWI osFileBlock
CALL keepWhy
SETD.0 PastText
SWI osPrintString
CALL printWhy
INIA 0d1
SWI osExit
noBig:
CALL keepWhy
SETD.0 NoBigText
SWI osPrintString
CALL printWhy
INIA 0d1
SWI osExit
noSmall:
SETD.0 NoSmallText
SWI osPrintString
INIA 0d1
SWI osExit
noRename:
SETD.0 NoRenameText
SWI osPrintString
INIA 0d1
SWI osExit
; ---- Routines ----
; Block zero of the big file, into the running checksum.
readFirstBig:
SETD.0 BigName
SETD.1 Block
RSTA
RSTB
SWI osFileBlock
BNQ readFirstDone
CALL takeCount
SETD.1 Block
CALL checksum
readFirstDone:
RET
; What the service just answered in DP3 becomes Left, which is what the checksum counts
; down. Kept in memory rather than in a pointer because a CALL does not preserve one.
takeCount:
PSHD.3
POPB
POPA
SETD.2 Left
STA.2
INCD.2
STB.2
RET
; Adds the bytes at DP1 into the running checksum, as many of them as Left says.
;
; Two accumulators, each a byte wide, each throwing away what carries off the top. The
; first is the sum of the bytes and the second is the sum of the first, so a byte that
; arrives late counts for less than one that arrived early - which is what makes this
; notice a block delivered out of turn.
checksum:
checksumLoop:
LDA.1
SETD.2 Fletch1
LDB.2
CCF
ADD
MVQA
STA.2
SETD.2 Fletch2
LDB.2
CCF
ADD
MVQA
STA.2
INCD.1
; Left goes down by one, sixteen bits of it: a whole block is 256 bytes and a whole file
; is more than one block, so a byte counter would not reach.
SETD.2 Left
INCD.2
LDA.2
BNA checksumLow
DECD.2
LDA.2
DECA
STA.2 ; Borrow out of the high byte.
INCD.2
INIA 0xFF
STA.2
BRI checksumTest
checksumLow:
DECA
STA.2
checksumTest:
SETD.2 Left
LDA.2
INCD.2
LDB.2
OR ; Zero only when both halves are.
BNQ checksumLoop
RET
clearChecksum:
RSTA
SETD.2 Fletch1
STA.2
SETD.2 Fletch2
STA.2
RET
clearIndex:
RSTA
SETD.2 Index
STA.2
INCD.2
STA.2
RET
stepIndex:
SETD.2 Index
INCD.2
LDA.2
INCA
STA.2
BNC stepIndexDone
DECD.2
LDA.2
INCA
STA.2
stepIndexDone:
RET
; Puts the checksum aside so that a second one can be compared with it.
keepChecksum:
SETD.2 Fletch1
LDA.2
SETD.2 Kept1
STA.2
SETD.2 Fletch2
LDA.2
SETD.2 Kept2
STA.2
RET
; Q is zero if the running checksum is the one that was put aside.
sameAsKept:
SETD.2 Fletch1
LDA.2
SETD.2 Kept1
LDB.2
XOR
BNQ sameAsKeptDone
SETD.2 Fletch2
LDA.2
SETD.2 Kept2
LDB.2
XOR
sameAsKeptDone:
RET
printChecksum:
SETD.2 Fletch1
LDA.2
SETD.2 Fletch2
LDB.2
SWI osPrintNumber
RET
printKept:
SETD.2 Kept1
LDA.2
SETD.2 Kept2
LDB.2
SWI osPrintNumber
RET
; Why the last service said no. Q survives a CALL, which is the only reason this can be a
; routine at all, but it does not survive the next SWI - so it is written down here and
; printed later, with whatever has to happen in between happening in between.
keepWhy:
MVQA
SETD.2 Why
STA.2
RET
printWhy:
RSTA
SETD.2 Why
LDB.2
SWI osPrintNumber
SETD.0 NewLine
SWI osPrintString
RET
#Data
#Base 0x3000
BigName:
"big.txt"
SmallName:
"small.txt"
OtherName:
"moved.txt"
MissingName:
"nothing.txt"
BigIs:
"big.txt is "
BlocksText:
" blocks
"
ReadText:
"read "
BlocksSumText:
" blocks, checksum "
StoppedText:
", stopped with "
BothText:
"small.txt streamed is "
AgainstText:
", read whole is "
SameText:
"the same
"
DifferText:
"DIFFERENT
"
InterleaveOk:
"the same block twice with another file between: the same
"
InterleaveBad:
"the same block twice with another file between: DIFFERENT
"
MovedText:
"renamed small.txt
"
OldNameText:
"the old name now answers "
NewNameText:
"the new name answers "
MissingText:
"a name that was never there answers "
PastText:
"a block past the end answers "
NoBigText:
"big.txt would not open, answer "
NoSmallText:
"small.txt would not read
"
NoRenameText:
"it would not rename
"
NewLine:
"
"
Index:
0x00 0x00
Left:
0x00 0x00
Fletch1:
0x00
Fletch2:
0x00
Kept1:
0x00
Kept2:
0x00
Why:
0x00
; One block, which is the whole point: the big file is four hundred times this.
Block:
#Reserve 0d256
; And room for the small one all at once, so that the two ways of reading it can be
; compared against each other.
Whole:
#Reserve 0d1024
+88
View File
@@ -0,0 +1,88 @@
; Print a text file without asking it to fit in Data Memory.
; fileStream.asm owns finding the file and walking its blocks. Type owns only what makes
; it Type: send each byte to the console until the stream says there are no more.
;
; Written by ChatGPT for Anachronaut's SplitBit
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Name
INIB 0d63
SWI osArgument
SETD.0 Name
LDA.0
BRA noName
CALL fileStreamOpen
BNQ openFailed
nextBlock:
CALL fileStreamNext
BNQ readFailed
PSHD.3
POPB
POPA
SETD.2 Remaining
STA.2
INCD.2
STB.2
OR
BRQ finished
SETD.1 FileStreamBlock
SETD.2 Remaining
printLoop:
LDA.1
OUTA 0x00
INCD.1
CALL fileStreamTakeRemaining
BRQ nextBlock
BRI printLoop
noName:
SETD.0 Usage
SWI osPrintString
INIA 0d2
SWI osExit
openFailed:
SETD.0 OpenError
SWI osPrintString
BRI printError
readFailed:
SETD.0 ReadError
SWI osPrintString
printError:
RSTA
MVQB
SWI osPrintNumber
SETD.0 NewLine
SWI osPrintString
; ITS OWN EXIT, and it did not have one. This fell through into finished and reported
; that everything was fine, having just printed the reason it was not - which nobody
; noticed while the only reader was a person, who could see both.
INIA 0d1
SWI osExit
finished:
RSTA
SWI osExit
#Data
#Base 0x3000
Usage:
"type: give me a file name
"
OpenError:
"type: cannot find the file, error "
ReadError:
"type: cannot read the file, error "
NewLine:
0x0A 0x00
Name:
#Reserve 0d64
Remaining:
0x00 0x00
#Include fileStream.asm
+137
View File
@@ -0,0 +1,137 @@
; A program that says what is on the disk.
;
; Which no program could do until now. Everything the system offered took a name a program
; already knew - read it, save it, rename it, delete it, ask how big it is - and there was no
; way to ask what names there are. dir could list only because it lives in the shell and calls
; the filesystem directly. So a file manager, a backup, and the package manager still to come
; were all unwritable for want of two services.
;
; This is those two, used as simply as they can be: walk, say what each thing is, and ask
; about it.
;
; > Walk
; f Say.sbx 1
; d Apps 0
; u halfsaved.txt 3
;
; ---- And it asks about each one AS IT GOES, which is the point ----
;
; Where a walk has got to is the system's, and so is where the last file it was asked about
; lives - the same eight bytes of filesystem state. A program that walks a directory and asks
; osFileInfo about each entry is using both at once, which is the obvious thing to write and
; the thing that would quietly go wrong if the two shared a position.
;
; Calling osFileInfo between two steps of the walk is therefore not decoration here. It is the
; check, and a walk that came back wrong afterwards would show up as a short listing or a
; repeated name rather than as an error.
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Name
INIB 0d24
SWI osDirFirst
BRI walkCheck
walkStep:
SETD.0 Name
INIB 0d24
SWI osDirNext
walkCheck:
; The kind is written down before anything else wants the registers: the comparison below
; is an ALU operation and Q is where its answer goes.
MVQA
SETD.0 Kind
STA.0
INIB 0xFF
CCF
SUB
BRQ walkDone
; f, d or u. A chain rather than a table, because three is not enough to index - and A is
; still the kind, since SUB writes Q and leaves it alone.
BRA walkFile
INIB 0d1
CCF
SUB
BRQ walkDirectory
SETD.0 Unfinished
BRI walkSay
walkDirectory:
SETD.0 Directory
BRI walkSay
walkFile:
SETD.0 File
walkSay:
SWI osPrintString
SETD.0 Name
SWI osPrintString
SETD.0 Space
SWI osPrintString
; ---- And how big it is, asked BETWEEN two steps of the walk ----
;
; The name is the only thing the walk hands over, which is what keeps it from being a record
; both sides have to agree the shape of. A program that wants more asks about the name, the
; same way anything else does.
;
; A directory is not asked about. osFileInfo answers for one perfectly well and says nought
; blocks, which is true and reads as a size - and nought is a size a file can genuinely
; have, so the two would be indistinguishable in the listing.
SETD.0 Kind
LDA.0
INIB 0d1
CCF
SUB
BRQ walkNoSize
SETD.0 Name
SWI osFileInfo
BNQ walkNoSize
PSHD.3
POPB
POPA
SWI osPrintNumber
BRI walkEnded
walkNoSize:
SETD.0 NoSize
SWI osPrintString
walkEnded:
SETD.0 NewLine
SWI osPrintString
BRI walkStep
walkDone:
RSTA
SWI osExit
#Data
#Base 0x3000
File:
"f "
Directory:
"d "
Unfinished:
"u "
Space:
" "
NoSize:
"-"
NewLine:
0x0A 0x00
Kind:
0x00
Name:
#Reserve 0d24
+109
View File
@@ -0,0 +1,109 @@
; Goes somewhere else and reads a file by a bare name once it is there.
;
; This exists to prove two things the shell promises and nothing else could test. First
; that osChangeDir works: the file it prints is named with no path at all, so the only way
; to reach it is to be standing in the right place. Second that the shell puts the working
; directory back afterwards: the prompt after this returns says where the shell was, not
; where this went.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; Where to go is the argument. Nothing else about this program says a directory name, so
; running it anywhere else moves it anywhere else.
SETD.0 Where
INIB 0d63
SWI osArgument
SETD.0 Where
LDA.0
BRA noWhere
SETD.0 Where
SWI osChangeDir
BNQ noSuchPlace
SETD.0 Went
SWI osPrintString
; And now a bare name, which means nothing until you are somewhere.
SETD.0 Bare
CALL fileStreamOpen
BNQ noFile
wanderBlock:
CALL fileStreamNext
BNQ noFile
PSHD.3
POPB
POPA
SETD.2 Left
STA.2
INCD.2
STB.2
SETD.2 Left
LDA.2
INCD.2
LDB.2
OR
BRQ wanderDone
SETD.0 FileStreamBlock
SETD.2 Left
INCD.2
LDB.2
wanderByte:
LDA.0
OUTA 0x00
INCD.0
DECB
BNB wanderByte
BRI wanderBlock
wanderDone:
RSTA
SWI osExit
noWhere:
SETD.0 NoWhereText
SWI osPrintString
INIA 0d1
SWI osExit
noSuchPlace:
SETD.0 NoPlaceText
SWI osPrintString
INIA 0d1
SWI osExit
noFile:
SETD.0 NoFileText
SWI osPrintString
INIA 0d1
SWI osExit
#Data
#Base 0x3000
Where:
#Reserve 0d64
Left:
0x00 0x00
Went:
"moved, and reading a bare name from there:
"
NoWhereText:
"wander where?
"
NoPlaceText:
"cannot go there
"
NoFileText:
"nothing of that name here
"
Bare:
"notes.txt"
#Include fileStream.asm
+100
View File
@@ -0,0 +1,100 @@
; A program that says where it came from.
;
; The dullest thing on the disk, and that is the point of it: osWhereAmI and pathBeside are
; what an application with assets stands on, and both are much easier to check now, with
; nothing depending on them, than later inside a game that has gone quiet.
;
; Where says the path it was loaded from
; Where splash.tune says that, and the path of splash.tune beside it
;
; ---- The thing worth checking ----
;
; The answer follows the PROGRAM and not the person. Run this from the root and it says one
; thing; walk somewhere else and run the same copy and it says the same thing, because the
; system worked it out when the program started and a program's own place cannot move under
; it. A working directory is where the person is standing, and it is what the argument means:
; "Where notes.txt" asks about a notes.txt in the program's place, not in yours, which is
; exactly the distinction an application needs and the reason cwd could not serve both.
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 MyPath
INIB 0d192
SWI osWhereAmI
SETD.0 FromText
SWI osPrintString
SETD.0 MyPath
SWI osPrintString
CALL newLine
; With nothing after it, that is the whole of what it has to say.
SETD.0 Given
INIB 0d64
SWI osArgument
SETD.0 Given
LDA.0
BRA whereDone
SETD.0 MyPath
SETD.1 Given
SETD.2 Beside
INIB 0d192
CALL pathBeside
BNQ whereTooLong
SETD.0 BesideText
SWI osPrintString
SETD.0 Beside
SWI osPrintString
CALL newLine
whereDone:
RSTA
SWI osExit
whereTooLong:
; A path that did not fit is a wrong answer, and saying it would be worse than saying so.
SETD.0 TooLongText
SWI osPrintString
CALL newLine
INIA 0x01
SWI osExit
newLine:
SETD.0 NewLine
SWI osPrintString
RET
#Data
#Base 0x3000
FromText:
"loaded from "
BesideText:
"beside it: "
TooLongText:
"that path would not fit"
NewLine:
0x0A 0x00
MyPath:
#Reserve 0d192
Beside:
#Reserve 0d192
Given:
#Reserve 0d64
; ---- Included last, after both segments have been based ----
;
; A #Base says where a whole segment begins and so has to be the first thing in it, and a
; library brings code and data of its own. Put in front, its #Data would be the first thing
; in the Data Segment and this program's own #Base would be too late. This is where Lander
; keeps its seven, for the same reason.
#Include path.asm
+3 -2
View File
@@ -17,7 +17,7 @@
#Program #Program
#Base 0x2000 ; Above the system, which keeps below here. #Base 0x5000 ; Above the system, which keeps below here.
greet: greet:
SETD.0 Opening SETD.0 Opening
@@ -39,11 +39,12 @@ greet:
; Give the machine back. The system takes its Stack back at this point, so everything ; Give the machine back. The system takes its Stack back at this point, so everything
; this program pushed goes with it. ; this program pushed goes with it.
RSTA
SWI osExit SWI osExit
#Data #Data
#Base 0x1000 ; And its data above the system's. #Base 0x3000 ; And its data above the system's.
Opening: Opening:
"a program, loaded off a disk, running on the system that loaded it "a program, loaded off a disk, running on the system that loaded it
+3 -2
View File
@@ -6,7 +6,7 @@
#Program #Program
#Base 0x2000 ; Change two: #Base 0x5000 ; Change two:
SETD hello ; Change three SETD hello ; Change three
Start: Start:
@@ -21,10 +21,11 @@ End:
OUTA 0x00 ; Output it to the text console. OUTA 0x00 ; Output it to the text console.
;HALT ; Terminate the program. ;HALT ; Terminate the program.
; Instead, let's call osExit to return the system nicely. Fourth change. ; Instead, let's call osExit to return the system nicely. Fourth change.
RSTA
SWI osExit SWI osExit
#Data #Data
#Base 0x1000 ; Five, adjust the base of the data segment. #Base 0x3000 ; Five, adjust the base of the data segment.
hello: ; Throw a label here so we can explicitly point at this data. Six, actually. hello: ; Throw a label here so we can explicitly point at this data. Six, actually.
"Hello, World!" "Hello, World!"
+458
View File
@@ -0,0 +1,458 @@
; A listing laid out to be read.
;
; > ls
; Copy.sbx Say.sbx Where.sbx
; Walk.sbx Lander where.sh
;
; > ls /Apps
;
; dir says what is there, one line each, with sizes and a tally of the disk. This says the
; names and nothing else, in columns, which is what you want ninety times in a hundred - and
; the two are separate programs rather than one with a switch because they answer different
; questions and neither answer is a worse version of the other.
;
; ---- Why this is a program and dir is not ----
;
; dir is built into the shell, and that is what makes it the one to trust when the disk is
; the thing being doubted: it is already in memory. This has to be loaded FROM the disk, so it
; cannot list a disk too broken to load it. The daily driver and the diagnostic, and it is
; worth having both.
;
; ---- Two passes, and no buffer at all ----
;
; Columns need the longest name before the first line can be printed, which usually means
; holding every name in memory - twenty four bytes each, and this format allows 1,024 entries
; on a disk. So instead the directory is walked TWICE: once to find the longest name and count
; them, once to print. A walk costs a read of each directory block and the disk buffer is
; already there, where a buffer for the names would be memory this program has to have whether
; the directory is large or not.
;
; ---- Across and then down, rather than down and then across ----
;
; Real listings go down the columns, so that names next to each other alphabetically are next
; to each other on the screen. THAT IS A REASON THAT DEPENDS ON SORTING, and nothing here
; sorts: entries come back in the order the directory holds them, which is the order they were
; made. With the order arbitrary, down-and-across buys nothing and costs a division to work
; out how many rows there are - on a machine that cannot divide.
;
; So this goes across. If sorting ever arrives, that is the moment to change it, and this
; comment is the reason why.
#Include services.asm
#Program
#Base 0x5000
start:
; A directory to list, if one was named. The shell puts the working directory back when a
; program exits, so this can walk off somewhere and not tidy up after itself.
SETD.0 Given
INIB 0d64
SWI osArgument
SETD.0 Given
LDA.0
BRA lsHere
SWI osChangeDir
BNQ lsNoSuchPlace
lsHere:
; ---- Pass one: how wide the widest of them is ----
RSTA
SETD.0 Longest
STA.0
SETD.0 Seen
STA.0
SETD.0 Name
INIB 0d24
SWI osDirFirst
BRI lsMeasure
lsMeasureNext:
SETD.0 Name
INIB 0d24
SWI osDirNext
lsMeasure:
MVQA
SETD.0 Kind
STA.0
INIB 0xFF
CCF
SUB
BRQ lsMeasured
SETD.0 Seen
LDA.0
INCA
STA.0
CALL nameLength
MVQA
; A directory wears a separator, which is a character of width like any other.
SETD.0 Kind
LDB.0
DECB
BNB lsMeasureWidth
INCA
lsMeasureWidth:
SETD.0 Longest
LDB.0
CCF
SUB
BRC lsMeasureKeep ; Borrowed, so this one is not the longest.
STA.0
lsMeasureKeep:
BRI lsMeasureNext
lsMeasured:
; Nothing at all is said with nothing at all, which is what an empty directory looks like.
SETD.0 Seen
LDA.0
BRA lsDone
; ---- How many of them fit across ----
;
; Two spaces between columns, so that names never touch. Worked out by taking the cell width
; off the screen width until there is not room for another, because this machine cannot
; divide and the answer is never more than a handful.
SETD.0 Longest
LDA.0
INCA
INCA
SETD.0 Cell
STA.0
INA 0x32
SETD.1 Across
STA.1 ; What is left of the line, counted down.
RSTA
SETD.1 Columns
STA.1
lsFitting:
SETD.1 Across
LDA.1
SETD.1 Cell
LDB.1
CCF
SUB
BRC lsFitted ; Borrowed, so there is not room for another column.
MVQA
SETD.1 Across
STA.1
SETD.1 Columns
LDA.1
INCA
STA.1
BRI lsFitting
lsFitted:
; A name wider than the screen still gets a column of its own. One that runs over is better
; than one that is not shown.
SETD.0 Columns
LDA.0
BNA lsPrint
INIA 0d1
STA.0
lsPrint:
RSTA
SETD.0 At
STA.0
SETD.0 Name
INIB 0d24
SWI osDirFirst
BRI lsSay
lsSayNext:
SETD.0 Name
INIB 0d24
SWI osDirNext
lsSay:
MVQA
SETD.0 Kind
STA.0
INIB 0xFF
CCF
SUB
BRQ lsLastLine
; ---- The cell the last name started, finished now rather than then ----
;
; Padding AFTER a name puts spaces at the end of every line that does not fill its last
; column, and trailing whitespace on a line is the kind of thing that is invisible until
; something else reads the output. Padding before the next name instead means the spaces
; only ever go between two things, and a line ends on the last character of a name.
SETD.0 At
LDA.0
BRA lsColour ; First on its line, so there is nothing to finish.
CALL padOut
lsColour:
; ---- What it is, said in colour ----
;
; The attribute reaches a terminal as well as the screen now, so this is one mechanism and
; not two. A directory in blue, something that will run in green, an unfinished save in red,
; and everything else plain.
SETD.0 Kind
LDA.0
BRA lsFileInk
DECA
BRA lsBlue
INIA 0d1 ; An unfinished save, which is worth looking at.
BRI lsInk
lsBlue:
INIA 0d4
BRI lsInk
lsFileInk:
; ---- Whether it will run, asked by looking ----
;
; This was priced once as needing a RUNNABLE BIT in the directory entry, set by the
; filesystem from a list of magic numbers it would have to be taught - which was declined,
; twice, for putting format knowledge in the filesystem and giving two implementations a
; registry to keep in step.
;
; It costs nothing of the sort now. The shell decides what to run by READING the first block
; of a file, so "will this run" is a question with an answer already, and asking it is the
; same question the shell would ask. Nothing new is written down and nothing has to agree
; about anything: if the shell learns a third kind of runnable file, this is wrong until
; somebody adds two lines, and wrong in the direction of a listing that is less colourful.
;
; The price is a block read per file, which is what the bit existed to avoid. It is paid
; here rather than in dir on purpose: dir is the listing you audit and is built into the
; shell, and this is the one you read and is a program that was loaded off the disk anyway.
CALL runnable
BNQ lsPlain
INIA 0d2
BRI lsInk
lsPlain:
RSTA
lsInk:
OUTA 0x06
SETD.0 Name
SWI osPrintString
CALL nameLength
MVQA
SETD.0 Kind
LDB.0
DECB
BNB lsWidth
PSHA
SETD.0 Slash
SWI osPrintString
POPA
INCA
lsWidth:
; How much of the cell this name took, for whoever finishes it.
SETD.0 Used
STA.0
; Back to plain before anything else is written, so that a scheme with paper in it would
; not paint the gap between the columns.
RSTA
OUTA 0x06
SETD.0 At
LDA.0
INCA
STA.0
SETD.0 Columns
LDB.0
CCF
SUB
BNQ lsSayNext ; Room for another on this line.
RSTA
SETD.0 At
STA.0
CALL newLine
BRI lsSayNext
lsLastLine:
; A line with something on it needs ending. One that ended on its last column does not, and
; a blank line under a listing looks like a listing with a gap in it.
SETD.0 At
LDA.0
BRA lsDone
CALL newLine
lsDone:
RSTA
SWI osExit
lsNoSuchPlace:
SETD.0 NoSuchPlace
SWI osPrintString
SETD.0 Given
SWI osPrintString
CALL newLine
INIA 0x01
SWI osExit
; ---- How wide the name in the buffer is ----
;
; Q COMES BACK THE COUNT, not A. A RET puts A back the way the caller had it, so a subroutine
; that answers in A answers with nothing - and this one did, which made every gap between the
; columns the same width because the padding was subtracting whatever A happened to hold.
; Q is the ALU's output and nothing puts it back, which is why every answer on this machine
; comes home in it.
nameLength:
SETD.0 Name
RSTA
lengthNext:
LDB.0
BRB lengthDone
INCA
INCD.0
BRI lengthNext
lengthDone:
RSTB
CCF
ADD
RET
; ---- Will the shell start this? ----
;
; Q is zero if it will. The same two things the shell itself looks for in a first block: SBEX
; for a program, "#!" for a script. A file too short to have a first block at all cannot be
; either, and osFileBlock saying no is that answer arriving for free.
runnable:
SETD.0 Name
SETD.1 Block
RSTA
RSTB
SWI osFileBlock
BNQ runnableNo
; The shebang first, because two bytes settle it.
SETD.0 Block
LDA.0
INIB 0d35 ; '#'
CCF
SUB
BNQ runnableExec
INCD.0
LDA.0
INIB 0d33 ; '!'
CCF
SUB
BRQ runnableYes
runnableExec:
SETD.0 Block
SETD.1 ExecText
INIA 0d4
SETD.2 Left
STA.2
runnableSame:
LDA.0
LDB.1
CCF
SUB
BNQ runnableNo
INCD.0
INCD.1
SETD.2 Left
LDA.2
DECA
STA.2
BNA runnableSame
runnableYes:
RSTA
RSTB
CCF
ADD
RET
runnableNo:
INIA 0x01
RSTB
CCF
ADD
RET
; A is how much of the cell the name took. Fills the rest of it with spaces.
padOut:
; The cell less what the name took, and IN THAT ORDER. Written the other way round first,
; which borrowed on every name that was not the longest - so every one of them took the
; "wider than its cell" path below and the whole listing came out separated by one space.
SETD.0 Cell
LDA.0
SETD.0 Used
LDB.0
CCF
SUB
BRC padDone ; Wider than its cell, so one space keeps the names apart.
MVQA
BRA padOne
padLoop:
PSHA
SETD.0 Space
SWI osPrintString
POPA
DECA
BNA padLoop
RET
padOne:
padDone:
SETD.0 Space
SWI osPrintString
RET
newLine:
SETD.0 NewLine
SWI osPrintString
RET
#Data
#Base 0x3000
ExecText:
"SBEX"
Slash:
"/"
Space:
" "
NoSuchPlace:
"there is no such directory: "
NewLine:
0x0A 0x00
Kind:
0x00
Longest:
0x00
Cell:
0x00
Columns:
0x00
Across:
0x00
At:
0x00
Seen:
0x00
Used:
0x00
Left:
0x00
Name:
#Reserve 0d24
Given:
#Reserve 0d64
; A whole block, because osFileBlock writes one whether the file fills it or not.
Block:
#Reserve 0d256
File diff suppressed because it is too large Load Diff
+882
View File
@@ -0,0 +1,882 @@
; What a token is.
;
; THE ORDER OF THESE TESTS IS THE LANGUAGE, and it is copied deliberately from the C
; assembler rather than reinvented, because the two have to produce the same bytes from
; the same source. A token is a keyword, then an instruction, then a literal value, then a
; string, then a label - and what a thing means depends on which of those it reaches first.
;
; ClsType 0 keyword 1 instruction 2 value 3 string
; 4 label definition 5 label reference
;
; A STRING IS NEVER ANYTHING ELSE. The quotes are gone by the time a token is looked at, so
; without that guard a string whose text reads "ADD" assembles as an instruction and one
; that begins with a zero is rejected as a malformed literal. Both have happened; the C
; assembler carries the same guard in two places and this carries it in four, because the
; keyword test needs it too and over there it does not have it.
;
; Written by Anachronaut
#Program
; Works out what TokText is. Q is zero if it is something the assembler understands.
clsToken:
SETD.0 ClsLength
CALL numZero
SETD.0 TokString
LDA.0
BNA clsIsString
; ---- A keyword ----
SETD.0 TokText
LDA.0
INIB 0x23 ; '#'
XOR
BNQ clsTryInstruction
RSTA
SETD.0 ClsType
STA.0
BRI clsYes
clsTryInstruction:
CALL clsInstruction
BNQ clsTryValue
INIA 0d1
SETD.0 ClsType
STA.0
BRI clsYes
clsTryValue:
; A leading zero means a literal was meant, so anything malformed after it is an error
; rather than a label. Falling through to the label test would quietly emit two bytes
; where one was wanted and shift everything after it.
SETD.0 TokText
LDA.0
INIB 0x30 ; '0'
XOR
BNQ clsTryLabel
CALL clsValue
BNQ clsNo
INIA 0d2
SETD.0 ClsType
STA.0
INIA 0d1
CALL clsSetLength
BRI clsYes
clsIsString:
INIA 0d3
SETD.0 ClsType
STA.0
; A string is its characters and the zero byte after them, which is why two strings
; written in a row are two strings rather than one long one.
;
; SIXTEEN BITS, and this is the token that needs them: a string may be 255 characters,
; which with its zero is 256, and 256 does not fit in a byte. Everything else here is 0,
; 1, 2 or 3.
SETD.0 ClsLength
CALL numZero
SETD.2 TokLength
LDA.2
SETD.0 ClsLength
CALL numAddByte
SETD.0 ClsLength
CALL numStep
BRI clsYes
clsTryLabel:
; A colon on the end makes it a definition. Everything else is a use of a name, which
; is two bytes of address wherever it appears.
CALL clsLastCharacter
SETD.0 ClsByte
LDA.0
INIB 0x3A ; ':'
XOR
BNQ clsUse
INIA 0d4
SETD.0 ClsType
STA.0
BRI clsYes
clsUse:
INIA 0d5
SETD.0 ClsType
STA.0
INIA 0d2
CALL clsSetLength
clsYes:
RSTA
RSTB
CCF
ADD
RET
clsNo:
RSTA
INIB 0d1
CCF
ADD
RET
; The last character of the token, into ClsByte. Zero if the token is empty.
;
; INTO MEMORY, not into A, and that is not a style choice: a CALL saves and restores A, B
; and Data Pointers 0 to 2, so a routine that leaves its answer in one of those has the
; answer undone by its own return. Only Q, DP3 and memory survive.
clsLastCharacter:
SETD.0 TokLength
LDA.0
BRA clsLastNone
SETD.0 TokText
SETD.1 ClsWalk
STD.0.1
SETD.0 TokLength
LDA.0
DECA
SETD.0 ClsWalk
CALL numAddByte
SETD.1 ClsWalk
LDD.0.1
LDA.0
SETD.0 ClsByte
STA.0
RET
clsLastNone:
RSTA
SETD.0 ClsByte
STA.0
RET
; ---- Instructions ----
; Is TokText an instruction? Q is zero if it is, and then ClsOpcode, ClsShape, ClsLength
; and ClsSelectorValue describe it.
;
; The name is folded to upper case and the selectors are split off before anything is
; looked up, because SETD.2 is the instruction SETD naming Data Pointer 2 rather than a
; name of its own.
clsInstruction:
CALL clsSplitName
BNQ clsInstructionNo ; Longer than any mnemonic, so it is not one.
CALL clsFindName
BNQ clsInstructionNo
; How many selectors this shape wants. They are emitted whether or not they were
; written, so the length is fixed by the instruction and leaving one off means zero.
SETD.0 ClsShape
LDA.0
SETD.0 AsmShapeSelectors
CALL clsIndexByte
SETD.0 ClsByte
LDA.0
SETD.0 ClsWanted
STA.0
; More selectors than the instruction has pointers to name is a mistake worth catching:
; it means the programmer thinks it does something it does not.
SETD.0 ClsGiven
LDA.0
SETD.2 ClsWanted
LDB.2
CCF
SUB
BRQ clsSelectorsFit
BRC clsSelectorsFit ; Fewer than wanted is allowed and means zero.
SETD.0 TooManySelectors
CALL clsComplain
BRI clsInstructionNo
clsSelectorsFit:
SETD.0 ClsWanted
LDA.0
INCA
CALL clsSetLength ; The opcode and its selectors. The operand is its own token.
RSTA
RSTB
CCF
ADD
RET
clsInstructionNo:
RSTA
INIB 0d1
CCF
ADD
RET
; Splits TokText into an upper case mnemonic in ClsName, padded to four with spaces, and
; up to two selector digits in ClsSelectorValue. Q is zero if the name could be a mnemonic
; at all, which means four characters or fewer.
clsSplitName:
INIA 0x20
SETD.0 ClsName
STA.0
INCD.0
STA.0
INCD.0
STA.0
INCD.0
STA.0
INCD.0
RSTA
STA.0 ; Four spaces and a zero, so a short name still compares.
RSTA
SETD.0 ClsGiven
STA.0
SETD.0 ClsSelectorValue
STA.0
INCD.0
STA.0
SETD.0 ClsNameLength
RSTA
STA.0
SETD.0 TokText
SETD.1 ClsWalk
STD.0.1
clsNameLoop:
SETD.1 ClsWalk
LDD.0.1
LDA.0
BRA clsSplitDone
INIB 0x2E ; '.'
XOR
BRQ clsSelectorPart
SETD.0 ClsNameLength
LDA.0
INIB 0d4
CCF
SUB
BNC clsSplitTooLong ; A fifth character, so this is not a mnemonic.
SETD.1 ClsWalk
LDD.0.1
LDA.0
CALL clsUpper
SETD.0 ClsByte
LDA.0
SETD.0 ClsName
SETD.2 ClsNameLength
CALL clsPutIndexed
SETD.0 ClsNameLength
LDA.0
INCA
STA.0
clsNameStep:
SETD.0 ClsWalk
CALL numStep
BRI clsNameLoop
clsSelectorPart:
; The character after the dot is which Data Pointer, in decimal.
SETD.0 ClsWalk
CALL numStep
SETD.1 ClsWalk
LDD.0.1
LDA.0
BRA clsSplitDone
INIB 0x30
CCF
SUB
MVQA ; The digit as a number.
SETD.0 ClsDigitHold
STA.0
; There are four Data Pointers, so anything above three does not name one.
INIB 0d4
CCF
SUB
BNC clsSelectorRange
SETD.0 ClsGiven
LDA.0
INIB 0d2
CCF
SUB
BNC clsSelectorSpare ; Already two, so anything more is counted and discarded;
; the count is what the caller complains about.
SETD.0 ClsDigitHold
LDA.0
SETD.0 ClsSelectorValue
SETD.2 ClsGiven
CALL clsPutIndexed
clsSelectorSpare:
SETD.0 ClsGiven
LDA.0
INCA
STA.0
BRI clsNameStep
clsSplitDone:
SETD.0 ClsNameLength
LDA.0
BRA clsSplitTooLong ; Nothing before the dot is not a mnemonic either.
RSTA
RSTB
CCF
ADD
RET
clsSelectorRange:
SETD.0 BadSelector
CALL clsComplain
clsSplitTooLong:
RSTA
INIB 0d1
CCF
ADD
RET
; Looks ClsName up in the instruction table. Q is zero if it is there, and then ClsOpcode
; and ClsShape say what it is.
clsFindName:
SETD.0 AsmInstructions
SETD.1 ClsEntry
STD.0.1
SETD.0 AsmInstructionCount
LDA.0
SETD.0 ClsLeft
STA.0
clsFindLoop:
SETD.1 ClsEntry
LDD.0.1
INCD.0
INCD.0 ; Past the opcode and the shape, to the name.
SETD.1 ClsName
CALL clsSameName
BRQ clsFindGot
; Seven bytes to an entry: an opcode, a shape, and four characters with a zero.
INIA 0d7
SETD.0 ClsEntry
CALL numAddByte
SETD.0 ClsLeft
LDA.0
DECA
STA.0
BNA clsFindLoop
RSTA
INIB 0d1
CCF
ADD
RET
clsFindGot:
SETD.1 ClsEntry
LDD.0.1
LDA.0
SETD.1 ClsOpcode
STA.1
SETD.1 ClsEntry
LDD.0.1
INCD.0
LDA.0
SETD.1 ClsShape
STA.1
RSTA
RSTB
CCF
ADD
RET
; Four characters at DP0 against four at DP1. Q is zero if they are the same.
clsSameName:
INIA 0d4
SETD.2 ClsLeft2
STA.2
clsSameLoop:
LDA.0
LDB.1
XOR
BNQ clsSameDone
INCD.0
INCD.1
SETD.2 ClsLeft2
LDA.2
DECA
STA.2
BNA clsSameLoop
clsSameDone:
RET
; ---- Literal values ----
; Is TokText a well formed literal? Q is zero if it is, and ClsValue is what it comes to.
; Anything beginning with a zero has to be one, so a failure here is an error rather than
; an invitation to try the next test.
;
; A LITERAL IS ONE BYTE WHEREVER IT GOES, so this is the byte-wide door onto clsWord below.
; The directives are the wide one: #Base takes an address and #Reserve a count, and neither
; would fit through here.
clsValue:
CALL clsWord
BNQ clsValueNo
SETD.0 ClsWord
LDA.0
BNA clsValueTooBig ; Something in the high byte, so it will not fit in one.
INCD.0
LDA.0
SETD.0 ClsValue
STA.0
RSTA
RSTB
CCF
ADD
RET
clsValueTooBig:
SETD.0 TooBig
CALL clsComplain
clsValueNo:
RSTA
INIB 0d1
CCF
ADD
RET
; Reads TokText as a sixteen bit number, into ClsWord. Q is zero if it is a well formed one.
;
; Both bases are here rather than in two routines because the only difference is which
; digits count and what to multiply by, and a number is written the same way wherever it
; appears - an address after #Base, a count after #Reserve, a byte in a segment.
clsWord:
SETD.0 TokText
INCD.0
LDA.0
INIB 0x78 ; 'x'
XOR
BRQ clsWordHex
SETD.0 TokText
INCD.0
LDA.0
INIB 0x64 ; 'd'
XOR
BRQ clsWordDecimal
SETD.0 BadPrefix
CALL clsComplain
BRI clsWordNo
clsWordHex:
INIA 0d16
SETD.0 ClsBase
STA.0
BRI clsWordDigits
clsWordDecimal:
INIA 0d10
SETD.0 ClsBase
STA.0
clsWordDigits:
SETD.0 TokLength
LDA.0
INIB 0d3
CCF
SUB
BRC clsWordEmpty ; Only the prefix, so there are no digits at all.
SETD.0 ClsWord
CALL numZero
SETD.0 TokText
INCD.0
INCD.0
SETD.1 ClsWalk
STD.0.1
clsWordLoop:
SETD.1 ClsWalk
LDD.0.1
LDA.0
BRA clsWordGood
CALL clsDigit
BNQ clsWordBadDigit
CALL clsWordTimesBase
BNQ clsWordTooBig
; And the digit on the end. A sum that comes out smaller than what went into it is a sum
; that went past sixteen bits, which is the only test needed and costs one comparison.
RSTA
SETD.0 ClsDigitWord
STA.0
INCD.0
SETD.2 ClsDigitValue
LDA.2
STA.0
SETD.0 ClsWord
SETD.2 ClsDigitWord
CALL numAdd
SETD.0 ClsWord
SETD.2 ClsDigitWord
CALL numCompare
BRC clsWordTooBig
SETD.0 ClsWalk
CALL numStep
BRI clsWordLoop
clsWordGood:
RSTA
RSTB
CCF
ADD
RET
clsWordEmpty:
SETD.0 NoDigits
CALL clsComplain
BRI clsWordNo
clsWordBadDigit:
SETD.0 BadDigit
CALL clsComplain
BRI clsWordNo
clsWordTooBig:
SETD.0 TooBigWord
CALL clsComplain
clsWordNo:
RSTA
INIB 0d1
CCF
ADD
RET
; ClsWord becomes itself times ClsBase. Q is not zero if that went past sixteen bits.
;
; By repeated addition, because this machine has no multiply. The base is ten or sixteen,
; so it is at most sixteen additions per digit, and a number in a source file has four or
; five digits.
clsWordTimesBase:
SETD.0 ClsAccum
CALL numZero
SETD.0 ClsMulLeft
SETD.2 ClsBase
LDA.2
STA.0
clsWordMulLoop:
SETD.0 ClsMulLeft
LDA.0
BRA clsWordMulDone
DECA
STA.0
SETD.0 ClsAccum
SETD.2 ClsWord
CALL numAdd
SETD.0 ClsAccum
SETD.2 ClsWord
CALL numCompare
BRC clsWordMulOver ; It came out smaller than what was added, so it wrapped.
BRI clsWordMulLoop
clsWordMulDone:
SETD.0 ClsWord
SETD.2 ClsAccum
CALL numSet
RSTA
RSTB
CCF
ADD
RET
clsWordMulOver:
RSTA
INIB 0d1
CCF
ADD
RET
; The character in A as a digit in ClsBase, into ClsDigitValue. Q is zero if it is one.
clsDigit:
SETD.0 ClsHold
STA.0
; 0 to 9
INIB 0x30
CCF
SUB
BRC clsDigitNo
SETD.0 ClsHold
LDA.0
INIB 0x3A
CCF
SUB
BRC clsDigitDecimal
; A to F, either case, and only when the base has room for them.
SETD.0 ClsBase
LDA.0
INIB 0d16
XOR
BNQ clsDigitNo
SETD.0 ClsHold
LDA.0
CALL clsUpper
SETD.0 ClsByte
LDA.0
SETD.0 ClsHold
STA.0
INIB 0x41
CCF
SUB
BRC clsDigitNo
SETD.0 ClsHold
LDA.0
INIB 0x47
CCF
SUB
BNC clsDigitNo
SETD.0 ClsHold
LDA.0
INIB 0x37 ; 'A' is ten, so the offset is 0x41 less 10.
CCF
SUB
MVQA
SETD.0 ClsDigitValue
STA.0
BRI clsDigitYes
clsDigitDecimal:
SETD.0 ClsHold
LDA.0
INIB 0x30
CCF
SUB
MVQA
SETD.0 ClsDigitValue
STA.0
; A decimal digit is a hexadecimal one too, so this needs no test of the base.
clsDigitYes:
RSTA
RSTB
CCF
ADD
RET
clsDigitNo:
RSTA
INIB 0d1
CCF
ADD
RET
; ---- Odds and ends ----
; Q is zero if the strings at DP0 and DP1 are the same, ignoring case.
;
; For the words that are part of the LANGUAGE rather than names somebody chose: Device, and
; the vectors the machine already uses. Mnemonics are matched the same way, for the same
; reason - nobody should have to remember how the manual capitalised something.
sameFolded:
LDA.0
CALL clsUpper
SETD.2 ClsByte
LDA.2
SETD.2 ClsFoldHold
STA.2
LDA.1
CALL clsUpper
SETD.2 ClsByte
LDA.2
SETD.2 ClsFoldHold
LDB.2
CCF
SUB
BNQ sameFoldedDone
LDA.0
BRA sameFoldedDone ; They ended together, so they matched all the way.
INCD.0
INCD.1
BRI sameFolded
sameFoldedDone:
RET
; ClsLength becomes the byte in A. Everything but a string is a small number, and this is
; how a small number is written into a sixteen bit field.
clsSetLength:
SETD.0 ClsLength
RSTB
STB.0
INCD.0
STA.0
RET
; The character in A, folded to upper case, into ClsByte.
clsUpper:
SETD.0 ClsHold
STA.0
INIB 0x61 ; 'a'
CCF
SUB
BRC clsUpperDone
SETD.0 ClsHold
LDA.0
INIB 0x7B ; One past 'z'.
CCF
SUB
BNC clsUpperDone
SETD.0 ClsHold
LDA.0
INIB 0d32
CCF
SUB
MVQA
SETD.0 ClsByte
STA.0
RET
clsUpperDone:
SETD.0 ClsHold
LDA.0
SETD.0 ClsByte
STA.0
RET
; The byte at DP0, offset by A, into ClsByte.
clsIndexByte:
PSHA
PSHD.0
POPB
POPA ; The low byte is on top, the way a pointer is pushed.
SETD.0 ClsWalk
STA.0
INCD.0
STB.0
POPA
SETD.0 ClsWalk
CALL numAddByte
SETD.1 ClsWalk
LDD.0.1
LDA.0
SETD.0 ClsByte
STA.0
RET
; Puts A at DP0 offset by the byte at DP2.
clsPutIndexed:
PSHA
PSHD.0
POPB
POPA
SETD.0 ClsPut
STA.0
INCD.0
STB.0
LDA.2
SETD.0 ClsPut
CALL numAddByte
SETD.1 ClsPut
LDD.0.1
POPA
STA.0
RET
; Says what is wrong, with the file and the line, the way an error ought to.
clsComplain:
SWI osPrintString
SETD.0 InFileText
SWI osPrintString
SETD.0 SrcName
SWI osPrintString
SETD.0 AtLineText
SWI osPrintString
SETD.0 TokLine
LDA.0
INCD.0
LDB.0
SWI osPrintNumber
SETD.0 SaidText
SWI osPrintString
SETD.0 TokText
SWI osPrintString
SETD.0 SaidEnd
SWI osPrintString
RET
#Data
ClsType:
0x00
ClsLength:
0x00 0x00
ClsOpcode:
0x00
ClsShape:
0x00
ClsSelectorValue:
0x00 0x00
ClsGiven:
0x00
ClsWanted:
0x00
ClsNameLength:
0x00
ClsName:
#Reserve 0d5
ClsValue:
0x00
ClsWord:
0x00 0x00
ClsAccum:
0x00 0x00
ClsDigitWord:
0x00 0x00
ClsBase:
0x00
ClsDigitValue:
0x00
ClsMulLeft:
0x00
ClsHold:
0x00
ClsDigitHold:
0x00
ClsByte:
0x00
ClsFoldHold:
0x00
ClsLeft:
0x00
ClsLeft2:
0x00
ClsWalk:
0x00 0x00
ClsEntry:
0x00 0x00
ClsPut:
0x00 0x00
InFileText:
" in "
AtLineText:
" at line "
SaidText:
"
it said: "
SaidEnd:
"
"
BadPrefix:
"a literal needs 0x for hexadecimal or 0d for decimal"
NoDigits:
"a literal with no digits after its prefix"
BadDigit:
"that is not a digit in the base the prefix asked for"
TooBig:
"a literal too large to fit in one byte"
TooBigWord:
"a number too large to fit in sixteen bits"
TooManySelectors:
"more Data Pointer selectors than that instruction has pointers to name"
BadSelector:
"that does not name a Data Pointer, which run from 0 to 3"
+434
View File
@@ -0,0 +1,434 @@
; The label table: the only thing that survives between the two passes.
;
; ---- KEPT IN ORDER, so that a lookup can halve it ----
;
; It used to be walked from the front, and that was most of the assembler. Every use of every
; label paid a scan of every label defined so far, so the cost grew with the program being
; built: assembling CosmOS on the machine took 1.83 billion cycles against the assembler's own
; 57 million - three times the source for thirty two times the time.
;
; Sorted and halved instead, it is 886 million: the walk was 52 per cent of the whole
; assembly. Small programs pay about a tenth more, because adding a label now moves the tail
; of the index up to make room and a short table was never expensive to walk. That is the
; right way round for a trade to fall.
;
; WHICH END IT IS SORTED FROM DOES NOT MATTER, and that is not carelessness. labAdd takes its
; insertion point from labFind, so the comparison that decides the order is the same one that
; searches it; turn it round and the table is built backwards and read backwards and nothing
; else can tell. Which is why breaking that comparison on purpose changes no output at all.
;
; Names are packed end to end in an arena and each index entry holds a pointer into it,
; rather than every entry carrying a field wide enough for the longest name. MEASURED on
; CosmOS, which is the biggest thing this will ever be asked to assemble: 453 labels
; averaging 11.3 characters. Packed they come to about 7,400 bytes; in 32 byte fields they
; would come to 15,400. The arena is worth the handful of extra instructions.
;
; Four bytes an index entry: two saying where the name is, two saying what it resolves to.
;
; A name is stored WITHOUT its colon, so that a definition and a use of it compare equal
; without either side having to know which it was looking at.
;
; The first pass fills this and the second only reads it. That is what makes a forward
; reference ordinary rather than special: by the time anything is emitted, every name in
; the program already has an address.
;
; Written by Anachronaut
#Program
; Empties the table.
labReset:
SETD.0 LabCount
CALL numZero
SETD.0 LabUsed
CALL numZero
SETD.0 LabNext
SETD.2 ScratchLabArena
CALL numSet
SETD.0 LabBase
SETD.2 ScratchLabIndex
CALL numSet
RET
; Adds the name at DP0, meaning the address in A and B. Q is zero if it went in.
;
; A name already in the table is refused rather than replaced: one name may mean one place,
; and quietly taking the second would move everything that referred to the first.
labAdd:
SETD.2 LabPutAddress
STA.2
INCD.2
STB.2
SETD.2 LabSubject
STD.0.2
CALL labFind
BNQ labAddFresh
SETD.0 LabTwice
CALL labComplain
BRI labAddNo
labAddFresh:
SETD.0 LabCount
SETD.2 LabLimit
CALL numCompare
BNC labAddFull ; The index is as full as it goes.
; And the arena, counting the zero that ends the name.
SETD.1 LabSubject
LDD.0.1
CALL labLength
SETD.0 LabEnd
SETD.2 LabUsed
CALL numSet
SETD.0 LabEnd
SETD.2 LabLength
CALL numAdd
SETD.0 LabRoom
SETD.2 LabEnd
CALL numCompare
BRC labAddCrowded ; The arena is smaller than where this name would end.
; ---- Room made for it where it belongs ----
;
; The index is kept in order so that looking a name up can halve the table instead of
; walking it, and labFind left LabLo at the place this name sorts to. Everything from there
; on moves up four bytes, from the END backwards so that nothing is written over before it
; has been read.
;
; It costs about a third of the table copied per label added, which against the walk it
; replaces is nothing: the walk was paid once per USE of a label and this is paid once per
; label, and there are several uses of each.
SETD.0 LabWhich
SETD.2 LabCount
CALL numSet
labAddShift:
SETD.0 LabWhich
SETD.2 LabLo
CALL numCompare
BRQ labAddPlace ; Down to where it goes, so there is nothing left to move.
; The one below this place, moved up into it.
CALL labEntryAt
SETD.1 LabEntry
LDD.1.1 ; DP1 is where it goes.
SETD.0 LabWhich
CALL numBack
CALL labEntryAt
SETD.0 LabEntry
LDD.0.0 ; DP0 is where it comes from.
INIB 0d4
labAddShiftByte:
LDA.0
STA.1
INCD.0
INCD.1
DECB
BNB labAddShiftByte
BRI labAddShift
labAddPlace:
SETD.0 LabWhich
SETD.2 LabLo
CALL numSet
CALL labEntryAt
SETD.1 LabEntry
LDD.0.1
SETD.1 LabNext
LDD.1.1
PSHD.1
POPB
POPA ; The low byte is on top, the way a pointer is pushed.
STA.0
INCD.0
STB.0
INCD.0
SETD.2 LabPutAddress
LDA.2
STA.0
INCD.0
INCD.2
LDA.2
STA.0
; And the name itself, into the arena.
SETD.1 LabNext
LDD.1.1
SETD.2 LabSubject
LDD.0.2
labAddLoop:
LDA.0
STA.1
BRA labAddCopied
INCD.0
INCD.1
BRI labAddLoop
labAddCopied:
INCD.1 ; Past the zero, which was copied with the rest.
SETD.0 LabNext
STD.1.0
SETD.0 LabUsed
SETD.2 LabLength
CALL numAdd
SETD.0 LabCount
CALL numStep
RSTA
RSTB
CCF
ADD
RET
labAddFull:
SETD.0 LabFull
CALL labComplain
BRI labAddNo
labAddCrowded:
SETD.0 LabNoRoom
CALL labComplain
labAddNo:
RSTA
INIB 0d1
CCF
ADD
RET
; Looks up the name at DP0. Q is zero if it is there, and then LabAddress is what it means.
;
; A straight walk from the front. With 453 labels and a few thousand uses of them that is
; the slowest thing the assembler does, and it is deliberately the simple version: sorting
; the table or bucketing it on the first character are both easy later, and neither is
; worth writing before anything has been measured.
labFind:
SETD.2 LabSought
STD.0.2
; ---- Halving the table rather than walking it ----
;
; The names are kept in order, so a lookup is eleven comparisons against two thousand
; entries where a walk was six hundred and seventy. THAT WAS MOST OF THE ASSEMBLER: every
; use of every label paid it, and it grew with the program being built, so assembling
; CosmOS on the machine cost 1.83 billion cycles against the assembler's own 57 million -
; three times the source for thirty two times the time.
;
; LabLo is left at the place the name WOULD go, which is what adding one needs and what a
; walk could never have handed back.
SETD.0 LabLo
CALL numZero
SETD.0 LabHi
SETD.2 LabCount
CALL numSet
labFindStep:
SETD.0 LabLo
SETD.2 LabHi
CALL numCompare
BNC labFindMissing ; Nothing left between them, so it is not here.
; The one in the middle. Adding two numbers under two thousand cannot overflow sixteen
; bits, so there is nothing to carry into.
SETD.0 LabWhich
SETD.2 LabLo
CALL numSet
SETD.0 LabWhich
SETD.2 LabHi
CALL numAdd
SETD.0 LabWhich
CALL numHalve
CALL labEntryAt
SETD.1 LabEntry
LDD.0.1
LDA.0
INCD.0
LDB.0
SETD.0 LabNamePointer
STA.0
INCD.0
STB.0
; ---- sameText is already an ordering ----
;
; Q is the difference at the first character that differed, so zero is a match - and the
; Carry Flag from that same subtraction says which way round they were, and survives the
; return because nothing puts the Status register back. A name that runs out while the
; other carries on borrows against the other's character, which sorts the shorter first,
; which is what anybody means by alphabetical.
SETD.1 LabNamePointer
LDD.0.1
SETD.1 LabSought
LDD.1.1
CALL sameText
BRQ labFindGot
BRC labFindAbove ; The one in the table is the smaller, so look above it.
SETD.0 LabHi
SETD.2 LabWhich
CALL numSet
BRI labFindStep
labFindAbove:
SETD.0 LabLo
SETD.2 LabWhich
CALL numSet
SETD.0 LabLo
CALL numStep
BRI labFindStep
labFindGot:
SETD.1 LabEntry
LDD.0.1
INCD.0
INCD.0
LDA.0
INCD.0
LDB.0
SETD.0 LabAddress
STA.0
INCD.0
STB.0
RSTA
RSTB
CCF
ADD
RET
labFindMissing:
RSTA
INIB 0d1
CCF
ADD
RET
; Where entry number LabWhich is, into LabEntry. Four bytes an entry, so the offset is the
; number doubled twice - there being no multiply on this machine, and none needed.
labEntryAt:
SETD.0 LabOffset
SETD.2 LabWhich
CALL numSet
SETD.0 LabOffset
SETD.2 LabOffset
CALL numAdd
SETD.0 LabOffset
SETD.2 LabOffset
CALL numAdd
SETD.0 LabEntry
SETD.2 LabBase
CALL numSet
SETD.0 LabEntry
SETD.2 LabOffset
CALL numAdd
RET
; How long the string at DP0 is, counting the zero on the end, into LabLength.
labLength:
SETD.1 LabLenWalk
STD.0.1
SETD.0 LabLength
CALL numZero
labLengthLoop:
SETD.0 LabLength
CALL numStep
SETD.1 LabLenWalk
LDD.0.1
LDA.0
BRA labLengthDone
SETD.0 LabLenWalk
CALL numStep
BRI labLengthLoop
labLengthDone:
RET
labComplain:
SWI osPrintString
SETD.0 LabNamed
SWI osPrintString
SETD.0 TokText
SWI osPrintString
SETD.0 LabAtLine
SWI osPrintString
SETD.0 TokLine
LDA.0
INCD.0
LDB.0
SWI osPrintNumber
SETD.0 LabNewLine
SWI osPrintString
RET
#Data
LabCount:
0x00 0x00
LabUsed:
0x00 0x00
LabNext:
0x00 0x00
LabBase:
0x00 0x00
LabAddress:
0x00 0x00
LabPutAddress:
0x00 0x00
LabNamePointer:
0x00 0x00
LabSought:
0x00 0x00
LabSubject:
0x00 0x00
LabEntry:
0x00 0x00
LabOffset:
0x00 0x00
; Where a halving search has got to, and where a name that is not there would go. LabLo is
; what labAdd makes room at, which is the whole reason the search hands it back.
LabLo:
0x00 0x00
LabHi:
0x00 0x00
LabWhich:
0x00 0x00
LabLength:
0x00 0x00
LabLenWalk:
0x00 0x00
LabEnd:
0x00 0x00
; How many labels there may be, and how many bytes of name between them.
;
; SIZED FOR THE ASSEMBLER ITSELF, which turns out to be the largest thing it is asked to
; build: the assembler is about 555 labels, against CosmOS's 1,341 and 16,758 bytes of name
; measured 2026-09-06 - CosmOS is much the bigger of the two now, and was the smaller when
; this was written. Running into either
; limit says so rather than writing past the end of the table, and that is what it did. The buffers live
; above the program rather than inside it - see the scratch map in Asm.asm.
; Two thousand and forty eight names, and twenty six kilobytes to hold them in. Both have
; now been doubled twice, and BOTH TIMES THE NAMES RAN OUT FIRST with the index a couple of
; hundred behind them - 8,081 of 8,192 the first time, 16,758 of 16,384 the second. A name
; is thirteen bytes on average and an index entry is four, so the arena will always be the
; one that speaks; raising it alone would buy a few hundred labels and then the other wall.
; THESE TWO MUST AGREE WITH THE SCRATCH MAP, which says where the room actually is.
LabLimit:
0x08 0x00
LabRoom:
0x68 0x00
LabNamed:
": "
LabAtLine:
", at line "
LabNewLine:
"
"
LabTwice:
"that label is defined twice"
LabFull:
"too many labels"
LabNoRoom:
"no room left for label names"
+179
View File
@@ -0,0 +1,179 @@
; The small things every other part of the assembler needs: sixteen bit arithmetic, for
; something that counts in addresses, and one string comparison.
;
; sbfs.asm has routines like these and the assembler cannot use them: it does not include
; the filesystem, because it reaches the disk through the system's services instead. That
; is the no-linker tax, paid in about a hundred and fifty bytes, and it is cheaper than the
; two and a half kilobytes including sbfs.asm would cost.
;
; Everything here works on numbers in memory rather than in registers, because a CALL puts
; A, B and Data Pointers 0 to 2 back as it found them. Only memory survives a return.
;
; Numbers are stored most significant byte first, the way every number on this machine is.
;
; Written by Anachronaut
#Program
; The two byte number at DP0 becomes the one at DP2. Destination first, so a call reads
; the way an assignment does.
numSet:
LDA.2
STA.0
INCD.2
INCD.0
LDA.2
STA.0
RET
; The two byte number at DP0 becomes itself plus the one at DP2.
numAdd:
INCD.0
INCD.2
LDA.0
LDB.2
CCF
ADD
MVQA
STA.0
DECD.0
DECD.2
LDA.0
LDB.2
ADD ; Carries in from the low half. Nothing between touches it.
MVQA
STA.0
RET
; The two byte number at DP0 becomes itself less the one at DP2.
numTake:
INCD.0
INCD.2
LDA.0
LDB.2
CCF
SUB
MVQA
STA.0
DECD.0
DECD.2
LDA.0
LDB.2
SUB ; Borrows in from the low half.
MVQA
STA.0
RET
; Adds the byte in A to the two byte number at DP0.
numAddByte:
INCD.0
LDB.0
CCF
ADD
MVQA
STA.0
BNC numAddByteDone
DECD.0
LDA.0
INCA
STA.0
numAddByteDone:
RET
; Adds one to the two byte number at DP0.
numStep:
INCD.0
LDA.0
INCA
STA.0
BNC numStepDone ; It did not wrap, so the high byte is untouched.
DECD.0
LDA.0
INCA
STA.0
numStepDone:
RET
; Compares the two byte number at DP0 with the one at DP2. Q is zero if they are equal,
; and the Carry Flag is set if the one at DP0 is the smaller. Both come back, because
; neither Q nor the Status register is put back by a return.
numCompare:
LDA.0
LDB.2
CCF
SUB ; The high bytes settle it unless they are the same.
BNQ numCompareDone
INCD.0
INCD.2
LDA.0
LDB.2
CCF
SUB
numCompareDone:
RET
; Takes one off the two byte number at DP0. The mirror of numStep, and wanted for the same
; reason: walking an index backwards is what moving a run of entries up needs.
numBack:
INCD.0
LDA.0
BNA numBackLow ; The low half has something to take, so the high half is safe.
DECD.0
LDA.0
DECA
STA.0
INCD.0
LDA.0
numBackLow:
DECA
STA.0
RET
; The two byte number at DP0 becomes half of itself.
;
; A and B are a CIRCULAR sixteen bit register, so a rotate right brings bit nought back in at
; the top rather than dropping it - which is a halving only once that bit is taken off again.
; The numbers this is asked about are index positions, well under thirty two thousand, so the
; top bit was nought before the rotate and clearing it afterwards loses nothing.
numHalve:
LDA.0
INCD.0
LDB.0
SHR
; The low half goes down FIRST, because taking the wrapped bit off the high half wants B
; for the mask and there is nowhere else to keep it.
STB.0
INIB 0x7F
AND
MVQA
DECD.0
STA.0
RET
; Q is zero if the strings at DP0 and DP1 are the same, both ending in a zero byte.
;
; Down here rather than with the label table, where it started, because four separate
; parts want it: labels, vector names, which file has already been included, and which
; directive a keyword is.
sameText:
LDA.0
LDB.1
CCF
SUB
BNQ sameTextDone
LDA.0
BRA sameTextDone ; They ended together, so they matched all the way.
INCD.0
INCD.1
BRI sameText
sameTextDone:
RET
; The two byte number at DP0 becomes zero.
numZero:
RSTA
STA.0
INCD.0
STA.0
RET
+85
View File
@@ -0,0 +1,85 @@
; The source reader on its own, before anything is built on top of it.
;
; Everything else in the assembler reads its source through srcNext, so a fault in here
; would turn up later as a mysterious wrong byte in an output file. It is worth checking by
; itself, against a file whose contents are already known.
;
; It reads whatever it was told to, prints every character back, and then says how many
; lines went past. The file it is given is deliberately bigger than one block, so the seam
; between one block and the next is crossed rather than assumed.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Wanted
INIB 0d23
SWI osArgument
SETD.0 Wanted
LDA.0
BRA nothingAsked
SETD.0 Wanted
CALL srcOpen
BNQ noFile
readLoop:
CALL srcNext
BNQ readDone
SETD.0 SrcChar
LDA.0
OUTA 0x00 ; Straight to the console: one character is not a string.
BRI readLoop
readDone:
SETD.0 LinesText
SWI osPrintString
SETD.0 SrcLine
LDA.0
INCD.0
LDB.0
SWI osPrintNumber
SETD.0 NewLine
SWI osPrintString
SWI osExit
nothingAsked:
SETD.0 AskText
SWI osPrintString
SWI osExit
noFile:
SETD.0 NoFileText
SWI osPrintString
SWI osExit
#Data
#Base 0x3000
Wanted:
#Reserve 0d23
LinesText:
"---- lines: "
NewLine:
"
"
AskText:
"say which file
"
NoFileText:
"no such file
"
; The libraries go last, after both segments have been based. An included file that carries
; code brings its own #Program and #Data lines with it, and a #Base has to come before
; anything is in the segment it bases - so the bases are set here and the code arrives after.
#Include scratch.asm
#Include numbers.asm
#Include source.asm
+108
View File
@@ -0,0 +1,108 @@
; Where the assembler's big buffers live.
;
; A map rather than a set of declarations, and it has a file of its own because the reader
; and the label table both need addresses out of it while neither includes the other.
;
#Data
; NOT #Reserve, AND THAT IS THE WHOLE POINT. Reserved space in a segment is written into
; the file as zeroes and copied at load, so 22K of scratch made a 34K file - and a loaded
; program is staged at 0x8000 before being put in place, which leaves exactly 32,768 bytes
; for the whole of it. The assembler could not load itself.
;
; None of this is initialised data. It is scratch, wanted only while the assembler is
; running, and while it is running everything above its own data is free: the system keeps
; below 0x2FFF, the staging area is only in use during a load, and the Stack comes down
; from the top. So the addresses are written down here and the file carries none of it.
;
; That sentence said 0x1000 for a while after the system's half of Data Memory was
; doubled, twelve lines above the paragraph that explains the doubling. A stale number is
; bad enough; a stale number sitting next to the correction is worse, because whichever
; one a reader takes is a coin toss.
;
; 0x5000 8192 the label index, 2048 entries of four
; 0x7000 26624 the label names, packed end to end
; 0xD800 256 one block of the output file, on its way to the disk
; 0xD900 1792 free
; 0xE000 1792 the vector names, 64 entries of twenty eight
; 0xE700 2048 the reader's stack, six levels of 301
; 0xEF00 368 which files have been included, sixteen names of 23
;
; IT USED TO START AT 0x8000, and the reason given was that everything above the
; assembler's own data is free. That was true when it was written and stopped being true
; without anything noticing: the system kept below 0x1000 then, and its data now reaches
; 0x1FFF, and the assembler's own moved from 0x1000 to 0x2000 with it. The floor came up
; and the map stayed where it was, leaving sixteen kilobytes between the two that nothing
; touched.
;
; Starting above the assembler's own data takes that back. It began at 0x4000 with the data
; from 0x2000, and moved to 0x5000 when the system was given another page and every
; application's data moved to 0x3000 with it - THE FLOOR CAME UP A SECOND TIME, exactly as
; the paragraph above says it did the first, and this time the check below said so before
; anything ran: the assembler's data reached 0x40D6 and the index began at 0x4000, so the
; buffers were sitting on the variables.
;
; There is still nearly four kilobytes of slack in front of this, and room for the data to
; double before the two would meet. `make test` measures that gap now
; rather than trusting this paragraph, and measures the floor above as well, because both
; of those numbers describe the machine AROUND this file and neither is enforced by a line
; of code anywhere.
;
; THE ROOM WENT TO ALL THREE OF THE BUFFERS THAT WERE FULL, and there turned out to be
; three rather than one. The output was the obvious wall - cosmos.bin was 13,245 bytes
; against 13,312, which is sixty seven - so it was given the lot, and the very next thing
; added to the system ran out of LABEL NAMES instead, at 8,081 of 8,192. Two ceilings a
; hundred bytes apart look like one ceiling until the first is lifted.
;
; The index was a hundred and eighteen entries from the same place. So: names doubled,
; index doubled, and the output given what is left, which is still four and a half thousand
; bytes more than CosmOS needs today.
;
; THE OUTPUT IS NO LONGER HELD AT ALL. It used to be built whole in memory and handed over
; at the end, which is what made a buffer of eighteen kilobytes the largest thing this
; machine could assemble. The file is produced in order, so it is written as it is made,
; through one block of window - and the eighteen kilobytes that were its share are free.
;
; What to do with them is not obvious and does not have to be decided today. Nothing here
; is close to full: the names are at half, the index at a third, and the output has no
; ceiling of its own any more. Leaving the room unclaimed is better than sharing it out
; among buffers that do not need it, because an unclaimed page is available to whichever
; one turns out to want it.
;
; ---- And one turned out to want it ----
;
; Sixteen kilobytes of names was half full when that was written and ran out on 2026-09-06,
; on an ordinary day's work on the shell: 16,758 bytes of name against 16,384. The index was
; 1,341 of 1,536 in the same breath, which is the paragraph above this one happening again -
; two ceilings, and lifting only the one that spoke would have bought two hundred labels.
;
; So both, out of the free page, and both left at about a third clear rather than at half:
; the names to 26,624 and the index to 2,048. What is left over is 1,792 bytes, kept
; unclaimed for the same reason the seventeen kilobytes were.
;
; That ends at 0xF070, with the Stack coming down from 0xFFFF above it - nearly four
; kilobytes, against the tens of bytes of CALL frames this ever nests.
;
; The reader's levels went from 293 to 301 when an include gained somewhere to be looked
; for: the name in each level is a PATH now, and "/Lib/" is five characters of it. Six
; levels of 301 is 1806, so the room here has to stay above that - which is why the include
; list moved up rather than the stack simply being asked to fit.
;
; THE TWO THINGS THAT DECIDE THESE SIZES are the largest program it will be asked to build
; and the largest one it will be asked to read. Measured 2026-09-06: CosmOS is 1,341 labels
; and 16,758 bytes of name, and the assembler itself is far smaller. It was the other way
; round when this file was written - "the hardest thing this assembles is not the operating
; system, it is itself" - and the system has grown past it since. The numbers here are worth
; measuring again rather than believing, which is why they carry a date.
ScratchLabIndex:
0x50 0x00
ScratchLabArena:
0x70 0x00
ScratchWindow:
0xD8 0x00
ScratchVecNames:
0xE0 0x00
ScratchSrcStack:
0xE7 0x00
ScratchIncNames:
0xEF 0x00
+814
View File
@@ -0,0 +1,814 @@
; The source reader: characters out of a file of any size.
;
; Everything else in the assembler sits on this, so it is the first thing built and the
; thing most worth getting right. It hands out one character at a time and keeps a line
; number, which is what lets an error say where it happened rather than only what it was.
;
; A FILE IS NEVER HELD WHOLE. It arrives a block at a time through osFileBlock, into one
; buffer of 256 bytes, and is fetched again when the buffer runs out. That is why the
; assembler can read a source file bigger than the memory it runs in - which cosmos.asm,
; at 56,047 bytes, already is.
;
; The file is read TWICE, once per pass, and srcRewind is how the second pass starts over.
; Nothing is kept between the passes but the label table.
;
; ---- A stack of readers ----
;
; #Include splices another file in where it stands, so the reader is a stack: srcInclude
; puts the current file's whole state aside, opens the new one, and the end of that file
; pops the old one back and carries on where it left off. Everything above works on "the
; current file" and does not know the stack is there.
;
; THE WHOLE STATE GOES ASIDE, buffer and all, 293 bytes of it. Keeping only the position
; and re-reading the block on the way back would be cheaper in memory and would cost a disk
; read per pop; at six levels of nesting the copy costs less than the arithmetic to avoid it.
; The buffer pointer survives the trip because it points into the buffer, which is always at
; the same address - the state is saved from and restored to the same variables.
;
; A FILE IS INCLUDED ONCE. Including it twice is not an error, it just does nothing, which
; is what lets two libraries depend on a third. The names are remembered for the length of
; one pass and forgotten between them, because the second pass has to walk exactly the same
; tree the first one did.
;
; Written by Anachronaut
#Program
; Opens the file DP0 names. Q is zero if it is there.
;
; The name is copied rather than pointed at, because the caller's copy is in the caller's
; memory and every later block read has to name the file again - there being no such thing
; as an open file to hold on to.
srcOpen:
SETD.1 SrcTopName
CALL srcKeepName ; Kept apart, so that each pass can open it again.
CALL srcRestart
RET
; Back to the top of the tree: the first file, no includes taken yet, nothing on the stack.
; This is what starts each pass.
srcRestart:
RSTA
SETD.0 SrcDepth
STA.0
SETD.0 IncCount
STA.0
SETD.0 SrcTopName
SETD.1 SrcName
CALL srcKeepName
CALL srcRewind
RET
; Back to the first character, for the second pass.
srcRewind:
SETD.0 SrcIndex
CALL numZero
SETD.0 SrcAt
CALL numZero
SETD.0 SrcCount
CALL numZero
RSTA
SETD.0 SrcEnded
STA.0
; The line number counts from one, the way an editor does.
SETD.0 SrcLine
CALL numZero
SETD.0 SrcLine
CALL numStep
; Ask how big it is, which is both the answer to "is it there" and the thing that says
; when to stop asking for blocks.
SETD.0 SrcName
SWI osFileInfo
BNQ srcRewindNo
PSHD.3
POPB
POPA
SETD.0 SrcBlocks
STA.0
INCD.0
STB.0
RSTA
RSTB
CCF
ADD ; Q is zero: it is there.
RET
srcRewindNo:
INIA 0d1
SETD.0 SrcEnded
STA.0
RSTA
INIB 0d1
CCF
ADD ; Q is not zero: it is not.
RET
; ---- Everything up to and including the next newline, thrown away ----
;
; Q is zero if a newline was found, and something else if the file ended first.
;
; THIS IS MOST OF WHAT THE ASSEMBLER READS. Measured on this repository: 87 per cent of what
; Say.asm pulls in is comment and blank line, and 59 per cent of CosmOS - the house style is
; dense commentary, so the assembler is penalised more than most by its own sources. Every one
; of those bytes used to go through tokGet and srcNext and be classified and thrown away.
;
; The saving is not the classifying, it is the BOOKKEEPING. srcNext loads and stores the
; walking pointer through memory for every single character, and asks numCompare whether the
; buffer is used up. Here the pointer lives in DP0 for a whole run and the newline lives in B,
; so a comment byte costs a load, a compare and two steps.
;
; A run is capped at 255 so that one byte can count it, which is why the outer loop exists at
; all: a full block is 256, one more than a byte holds.
srcSkipComment:
SETD.0 SrcEnded
LDA.0
BNA srcSkipEnded
srcSkipBlock:
; What is left of the buffer.
SETD.0 SrcSpan
SETD.2 SrcCount
CALL numSet
SETD.0 SrcSpan
SETD.2 SrcAt
CALL numTake
SETD.0 SrcSpan
LDA.0
INCD.0
LDB.0
OR
BRQ srcSkipRefill ; Used up, so the next block or the end.
SETD.0 SrcSpan
LDA.0
BNA srcSkipCapped ; A high half at all means 256, which is one too many to count.
INCD.0
LDA.0
BRI srcSkipCounted
srcSkipCapped:
INIA 0xFF
srcSkipCounted:
SETD.0 SrcRun
STA.0
SETD.0 SrcTaken
STA.0 ; Where the run began, to work out what it swallowed.
SETD.2 SrcPointer
LDD.0.2
SETD.1 SrcRun
INIB 0x0A ; The newline, held in B for the whole run.
srcSkipChar:
LDA.0
XOR
BRQ srcSkipNewline
INCD.0
LDA.1
DECA
STA.1
BNA srcSkipChar
; The run ended without a newline in it, so the rest of the block is comment too.
SETD.2 SrcPointer
STD.0.2
CALL srcSkipAccount
BRI srcSkipBlock
srcSkipNewline:
; Past the newline itself, which belongs to the comment.
INCD.0
SETD.1 SrcRun
LDA.1
DECA
STA.1
SETD.2 SrcPointer
STD.0.2
CALL srcSkipAccount
; A newline is what makes the next character part of the next line, counted as it goes past
; exactly as srcNext counts it.
SETD.0 SrcLine
CALL numStep
RSTA
RSTB
CCF
ADD
RET
srcSkipRefill:
CALL srcLoad
BRQ srcSkipBlock ; Another block of the same file.
; ---- A file that runs out inside a comment ----
;
; The same thing srcAtEnd does for a character, and it has to be done the same way here: an
; included file ending puts the one that included it back on, and the comment carries on
; into it exactly as it did when this was read a character at a time.
;
; NOT DOING THIS WAS THE WHOLE OF THE FIRST VERSION'S BUG. services.asm ends inside a
; comment, so an include of it never came back, and every label after the #Include line -
; the whole of the program doing the including - was quietly never defined. It surfaced as
; "no label of that name is defined anywhere" pointing at a line whose label was three
; lines from the end of the file.
INIA 0d1
SETD.0 SrcEnded
STA.0
SETD.0 SrcDepth
LDA.0
BRA srcSkipEnded ; Nothing included this, so there is no more source anywhere.
CALL srcPop
BRI srcSkipComment
srcSkipEnded:
INIA 0x01
RSTB
CCF
ADD
RET
; How much of the run went by, added to how far into the buffer we have read.
srcSkipAccount:
SETD.0 SrcTaken
LDA.0
SETD.0 SrcRun
LDB.0
CCF
SUB
MVQA
SETD.0 SrcAt
CALL numAddByte
RET
; The next character of the file, into SrcChar. Q is zero if there was one, and something
; else at the end of the file.
srcNext:
SETD.0 SrcEnded
LDA.0
BNA srcAtEnd
; ---- Is the buffer used up? ----
;
; SrcAt counts how far into it we have read and SrcCount how many of its bytes are the
; file's, which is 256 for every block but a short last one.
;
; ASKED HERE RATHER THAN BY numCompare, because this is the hottest question in the
; assembler - every character of every pass asks it - and a call to answer it costs more
; than the answer. The high bytes settle it almost every time and settle it FIRST: a full
; block is 256, so SrcCount's high byte is one and SrcAt's is nought until the last
; sixteenth of the block. Six instructions, where the call was twenty.
SETD.0 SrcAt
SETD.2 SrcCount
LDA.0
LDB.2
CCF
SUB
BNQ srcHaveByte
INCD.0
INCD.2
LDA.0
LDB.2
CCF
SUB
BNQ srcHaveByte
CALL srcLoad
BNQ srcAtEnd
srcHaveByte:
SETD.1 SrcPointer
LDD.0.1
LDA.0
INCD.0
STD.0.1
SETD.0 SrcChar
STA.0
; ---- A newline is what makes the next character part of the next line ----
;
; Counted here, as the character is handed out, so the line number always describes the
; character just given. A still holds it - nothing since the load has wanted it - where
; this used to fetch it back out of SrcChar it had just been put in.
INIB 0x0A
XOR
BNQ srcNextStep
SETD.0 SrcLine
CALL numStep
srcNextStep:
; And one further into the buffer, counted the same way and for the same reason.
SETD.0 SrcAt
INCD.0
LDA.0
INCA
STA.0
BNC srcNextDone
DECD.0
LDA.0
INCA
STA.0
srcNextDone:
RSTA
RSTB
CCF
ADD ; Q is zero: there was a character.
RET
srcAtEnd:
; This file is finished. If it was included by another, that one is not: it goes back on
; and the next character comes from where it left off, which is what makes an include
; read as though the text had been written there.
INIA 0d1
SETD.0 SrcEnded
STA.0
SETD.0 SrcDepth
LDA.0
BRA srcNothingLeft
CALL srcPop
BRI srcNext
srcNothingLeft:
RSTA
INIB 0d1
CCF
ADD ; Q is not zero: there is no more source anywhere.
RET
; ---- The stack ----
; Opens the file named at DP0 as though its text were written here. Q is zero if the
; reader is now inside it, or if it had already been included and there is nothing to do.
srcInclude:
SETD.1 IncWanted
CALL srcKeepName
CALL srcSeenAlready
BRQ srcIncludeSkip
SETD.0 SrcDepth
LDA.0
SETD.2 SrcDepthLimit
LDB.2
CCF
SUB
BNC srcTooDeep
CALL srcRemember
CALL srcPush
; TWO PLACES, TRIED IN ORDER: where you are, and then /Lib. The same rule the shell uses
; for a program it does not recognise, which is where it came from - a name means the
; one beside you if there is one, and the system's otherwise.
;
; It is what a search path is for, and the host assembler has had one since before there
; was a machine to run this on. Without it every source that calls a service has to sit
; in the same directory as services.asm, and the disk cannot be organised at all.
SETD.0 IncWanted
SETD.1 SrcName
CALL srcKeepName
CALL srcRewind
BRQ srcIncludeIn
CALL srcInLibrary
CALL srcRewind
BNQ srcIncludeGone
srcIncludeIn:
RET ; Q is zero, out of srcRewind.
; SrcName becomes the same name inside the library directory. Built here rather than kept
; as a second buffer, because what has to survive is the name the file was ASKED for -
; that is what the include-once list holds, and a file found in the library on one line
; and beside you on another is still the same include.
srcInLibrary:
SETD.0 SrcLibrary
SETD.1 SrcName
srcLibraryPrefix:
LDA.0
BRA srcLibraryName
STA.1
INCD.0
INCD.1
BRI srcLibraryPrefix
srcLibraryName:
SETD.0 IncWanted
srcLibraryCopy:
LDA.0
STA.1
BRA srcLibraryDone
INCD.0
INCD.1
BRI srcLibraryCopy
srcLibraryDone:
RET
srcIncludeSkip:
RSTA
RSTB
CCF
ADD ; Already in, so this line does nothing at all.
RET
srcIncludeGone:
; ---- Say which one ----
;
; It said nothing at all, and the assembler's catch-all then said "nothing was written" -
; which is true and useless. Three programs on the disk could be read and not assembled,
; and finding out why meant reading the assembler rather than the message.
;
; SrcName holds the library path by now, because that was the second place tried, so this
; says where it looked as well as what for. Both are worth having: a name that is not in
; /Lib and not beside you is usually one or the other spelt wrong.
SETD.0 GoneText
SWI osPrintString
SETD.0 IncWanted
SWI osPrintString
SETD.0 GoneWhereText
SWI osPrintString
SETD.0 SrcName
SWI osPrintString
SETD.0 GoneEndText
SWI osPrintString
; The file is not there. The stack is left as it is: the caller stops the assembly, and
; unwinding for the sake of tidiness would only hide where it happened.
RSTA
INIB 0d1
CCF
ADD
RET
srcTooDeep:
SETD.0 TooDeepText
SWI osPrintString
RSTA
INIB 0d1
CCF
ADD
RET
; Puts the current file aside and makes room for another.
srcPush:
CALL srcSlot
SETD.0 SrcState
SETD.1 SrcSlot
LDD.1.1
CALL srcCopyState
SETD.0 SrcDepth
LDA.0
INCA
STA.0
RET
; And takes it back.
srcPop:
SETD.0 SrcDepth
LDA.0
DECA
STA.0
CALL srcSlot
SETD.1 SrcSlot
LDD.0.1
SETD.1 SrcState
CALL srcCopyState
RET
; Puts one character back, so that the next read produces it again. The character itself
; is in A, because whether it was a newline decides whether a line goes back too.
;
; The tokenizer holds one character of lookahead, and at an #Include that character belongs
; to the file about to be put aside. Undoing the read is how it stays with that file: when
; the file is opened again the character is simply still there, and nothing has to be
; carried across the include or handed back at some moment chosen by the reader.
;
; CARRYING IT ACROSS WAS THE OBVIOUS THING AND IT WAS WRONG. A file runs out in the middle
; of whatever the tokenizer happens to be doing, so handing the character back then injects
; it into the middle of a word: `start:` came back as `s` and then `tart:`, which assembles
; into a file that looks entirely reasonable.
srcStepBack:
INIB 0x0A
XOR
BNQ srcStepBackAt
SETD.0 SrcLine
SETD.2 SrcOne
CALL numTake ; A newline not yet read has not started a line either.
srcStepBackAt:
SETD.0 SrcAt
SETD.2 SrcOne
CALL numTake
SETD.0 SrcPointer
SETD.2 SrcOne
CALL numTake
RET
; Where the slot for the current depth is, into SrcSlot.
srcSlot:
SETD.0 SrcSlot
SETD.2 ScratchSrcStack
CALL numSet ; WHERE the stack is, not what is in it.
SETD.0 SrcSlotLeft
SETD.2 SrcDepth
LDA.2
STA.0
srcSlotLoop:
SETD.0 SrcSlotLeft
LDA.0
BRA srcSlotDone
DECA
STA.0
SETD.0 SrcSlot
SETD.2 SrcStateBytes
CALL numAdd
BRI srcSlotLoop
srcSlotDone:
RET
; The whole reader state, from DP0 to DP1.
srcCopyState:
SETD.2 SrcCopyFrom
STD.0.2
SETD.2 SrcCopyTo
STD.1.2
SETD.0 SrcCopyLeft
SETD.2 SrcStateBytes
CALL numSet
srcCopyLoop:
SETD.1 SrcCopyFrom
LDD.0.1
LDA.0
SETD.1 SrcCopyTo
LDD.0.1
STA.0
SETD.0 SrcCopyFrom
CALL numStep
SETD.0 SrcCopyTo
CALL numStep
SETD.0 SrcCopyLeft
SETD.2 SrcOne
CALL numTake
SETD.0 SrcCopyLeft
LDA.0
INCD.0
LDB.0
OR
BNQ srcCopyLoop
RET
; ---- Which files have been in ----
; Q is zero if IncWanted has already been included in this pass.
srcSeenAlready:
RSTA
SETD.0 IncLeft
STA.0
srcSeenLoop:
SETD.0 IncLeft
LDA.0
SETD.2 IncCount
LDB.2
CCF
SUB
BRQ srcSeenNo
CALL srcSeenSlot
SETD.1 IncSlot
LDD.0.1
SETD.1 IncWanted
CALL sameText
BRQ srcSeenYes
SETD.0 IncLeft
LDA.0
INCA
STA.0
BRI srcSeenLoop
srcSeenYes:
RSTA
RSTB
CCF
ADD
RET
srcSeenNo:
RSTA
INIB 0d1
CCF
ADD
RET
; Writes IncWanted down as having been included.
srcRemember:
SETD.0 IncLeft
SETD.2 IncCount
LDA.2
STA.0
CALL srcSeenSlot
SETD.0 IncWanted
SETD.1 IncSlot
LDD.1.1
CALL srcKeepName
SETD.0 IncCount
LDA.0
INCA
STA.0
RET
; Where name number IncLeft sits, into IncSlot. Fixed fields of 23 bytes: there are few of
; these and they are short, so an arena would cost more code than it saved.
srcSeenSlot:
SETD.0 IncSlot
SETD.2 ScratchIncNames
CALL numSet
SETD.0 IncSlotLeft
SETD.2 IncLeft
LDA.2
STA.0
srcSeenSlotLoop:
SETD.0 IncSlotLeft
LDA.0
BRA srcSeenSlotDone
DECA
STA.0
INIA 0d23
SETD.0 IncSlot
CALL numAddByte
BRI srcSeenSlotLoop
srcSeenSlotDone:
RET
; Fetches the block SrcIndex names, and steps SrcIndex past it. Q is zero if there was one.
;
; Running off the end is not a failure here: osFileBlock answers three for a block past the
; end of the file, which is how a reader finds out it has finished. Any other refusal is a
; real one, and both come back the same way because there is nothing useful to do about
; either except stop.
srcLoad:
SETD.0 SrcName
SETD.1 SrcBuffer
SETD.2 SrcIndex
LDA.2
INCD.2
LDB.2
SWI osFileBlock
BNQ srcLoadNo
; DP3 says how many of the block's bytes belong to the file: a whole 256 except in a
; short last one, which is why it comes back in a pointer and not a register.
PSHD.3
POPB
POPA
SETD.0 SrcCount
STA.0
INCD.0
STB.0
SETD.0 SrcAt
CALL numZero
SETD.0 SrcIndex
CALL numStep
; The walking pointer starts at the front of the buffer again.
SETD.0 SrcBuffer
SETD.1 SrcPointer
STD.0.1
RSTA
RSTB
CCF
ADD
RET
srcLoadNo:
RSTA
INIB 0d1
CCF
ADD
RET
; Copies the name at DP0 into DP1, up to 22 characters of it and the zero after them,
; which is as long as a name on this filesystem may be.
srcKeepName:
INIA 0d22
SETD.2 SrcLeft
STA.2
srcKeepLoop:
LDA.0
BRA srcKeepEnd
STA.1
INCD.0
INCD.1
LDA.2
DECA
STA.2
BNA srcKeepLoop
srcKeepEnd:
RSTA
STA.1 ; The zero that makes it a string.
RET
#Data
; ---- The current file, as one block so that it can be put aside in one piece ----
;
SrcState:
; THIRTY TWO RATHER THAN THE TWENTY THREE A NAME NEEDS, because what goes here is a PATH:
; an include not found beside you is looked for in the library, and "/Lib/" plus a name of
; twenty two plus the zero that ends it is twenty eight. Every block of the file is asked
; for by this name, so it has to be the one that resolves, not the one that was typed.
SrcName:
#Reserve 0d32
SrcBlocks:
0x00 0x00
SrcIndex:
0x00 0x00
SrcCount:
0x00 0x00
SrcAt:
0x00 0x00
SrcLine:
0x00 0x00
SrcPointer:
0x00 0x00
SrcEnded:
0x00
; One block, which is the whole of what a source file costs in memory however big it is.
SrcBuffer:
#Reserve 0d256
; 301 bytes: a name of 32, six numbers of two, one single byte, and the buffer. NOTHING MAY
; BE ADDED IN THE MIDDLE OF THE BLOCK ABOVE without changing this to match, and the room
; set aside for six of them in scratch.asm has to be at least six times it.
SrcStateBytes:
0x01 0x2D
SrcDepthLimit:
0d6
; ---- OUTSIDE the state block above, and that is not an accident ----
;
; What is left of the buffer, how much of it one run may take, and where that run began.
; They live for the length of one call to srcSkipComment and never across an include, so they
; are not part of what a file remembers while another one is being read.
;
; PUT AMONG THE STATE FIRST, which added four bytes to a block that is copied whole by a
; count written down somewhere else - so every saved file lost the last four bytes of itself
; and an include came back with its buffer pointer wrong. The comment two paragraphs up says
; not to do it, and says it in capitals.
SrcSpan:
0x00 0x00
SrcRun:
0x00
SrcTaken:
0x00
; Where an include is looked for when it is not beside you. One fixed place rather than a
; list somebody sets, for the same reason the shell has one fixed place for programs: a
; list would need somewhere to live between one boot and the next.
SrcLibrary:
"/Lib/"
SrcOne:
0x00 0x01
SrcChar:
0x00
SrcLeft:
0x00
SrcDepth:
0x00
SrcSlot:
0x00 0x00
SrcSlotLeft:
0x00
SrcCopyFrom:
0x00 0x00
SrcCopyTo:
0x00 0x00
SrcCopyLeft:
0x00 0x00
; The file the assembly started from, so that each pass can open it again.
SrcTopName:
#Reserve 0d23
IncWanted:
#Reserve 0d23
IncCount:
0x00
IncLeft:
0x00
IncSlot:
0x00 0x00
IncSlotLeft:
0x00
GoneText:
"cannot find "
GoneWhereText:
", not beside the file that asked and not as "
GoneEndText:
"
"
TooDeepText:
"included files are nested deeper than this assembler will follow
"
+114
View File
@@ -0,0 +1,114 @@
; The instruction set, as the assembler needs to see it.
;
; A SECOND COPY, and it is worth saying why rather than hoping nobody notices. The monitor
; has one of these in cosmos.asm, and the assembler cannot use it: the monitor's copy lives
; in the system's data at an address that moves every time CosmOS is rebuilt, and there is
; no linker to reach it by name. So the assembler carries its own 448 bytes. That is the
; cost of having no libraries, paid where it is cheapest to pay.
;
; Both copies are generated by Tests/instructiontable.py from the C assembler's own list,
; and Tests/docs.sh checks both against it. Neither can drift without the suite saying so.
;
; Seven bytes an entry: the opcode, the shape, and four characters of name with the zero
; the assembler puts after a string. Every mnemonic is four characters or fewer, so a name
; padded to four is an exact match rather than a prefix.
;
; It goes in the DATA Segment, because the assembler has to read it and an instruction can
; only read Data Memory. A table in Program Memory could not be reached by the program
; holding it, except through the memory controller.
#Data
; How many bytes an instruction of each shape runs to, the opcode included. The assembler
; does not use this to size a token - the operand that follows is a token of its own and
; carries its own length - but it is what says an instruction is well formed.
AsmShapeLength:
0d1 0d3 0d2 0d2 0d3 0d4 0d3
; How many Data Pointer selectors an instruction of each shape names. This is what the
; assembler needs: a selector is part of the mnemonic rather than a token after it, so it
; is the one thing about an instruction's length that is not settled by the opcode alone.
;
; 0 no operand 4 a selector and a byte
; 1 an address 5 a selector and an address, which is SETD
; 2 a byte 6 two selectors, which is LDD and STD
; 3 a selector
AsmShapeSelectors:
0d0 0d0 0d0 0d1 0d1 0d1 0d2
AsmInstructionCount:
0d72
AsmInstructions:
0x10 0d0 "ADD "
0x11 0d0 "SUB "
0x12 0d0 "AND "
0x13 0d0 "OR "
0x14 0d0 "XOR "
0x15 0d0 "NOTA"
0x16 0d0 "NOTB"
0x17 0d0 "SHL "
0x18 0d0 "SHR "
0x60 0d1 "BRI "
0x61 0d1 "BRQ "
0x62 0d1 "BRA "
0x63 0d1 "BRB "
0x64 0d1 "BRC "
0x65 0d3 "BRD "
0x66 0d1 "BNQ "
0x67 0d1 "BNA "
0x68 0d1 "BNB "
0x69 0d1 "BNC "
0x70 0d1 "RCAL"
0x71 0d1 "CALL"
0x72 0d2 "SWI "
0x73 0d0 "RETI"
0x74 0d0 "RRET"
0x75 0d0 "RET "
0x76 0d0 "SRET"
0x20 0d0 "RSTA"
0x21 0d0 "RSTB"
0x22 0d0 "INCA"
0x23 0d0 "INCB"
0x24 0d0 "DECA"
0x25 0d0 "DECB"
0x26 0d2 "INIA"
0x27 0d2 "INIB"
0x28 0d0 "CCF "
0x29 0d0 "MVQA"
0x2A 0d0 "MVQB"
0x2B 0d0 "SIF "
0x2C 0d0 "CIF "
0x30 0d0 "PSHQ"
0x31 0d0 "PSHA"
0x32 0d0 "PSHB"
0x33 0d3 "PSHD"
0x34 0d0 "POPA"
0x35 0d0 "POPB"
0x36 0d3 "POPD"
0x40 0d3 "INCD"
0x41 0d3 "DECD"
0x42 0d3 "LDA "
0x43 0d3 "LDB "
0x44 0d3 "STQ "
0x45 0d3 "STA "
0x46 0d3 "STB "
0x47 0d5 "SETD"
0x48 0d4 "DPUP"
0x49 0d4 "DPDN"
0x4A 0d6 "LDD "
0x4B 0d6 "STD "
0x4C 0d3 "MVSD"
0x4D 0d3 "MVDS"
0x4E 0d3 "DPUA"
0x4F 0d3 "DPDA"
0x50 0d3 "DPUW"
0x51 0d3 "DPDW"
0xD0 0d2 "OUTQ"
0xD1 0d2 "OUTA"
0xD2 0d2 "OUTB"
0xE0 0d2 "INA "
0xE1 0d2 "INB "
0xF0 0d0 "NOP "
0xFE 0d0 "WAIT"
0xFF 0d0 "HALT"
+297
View File
@@ -0,0 +1,297 @@
; Tokens out of characters.
;
; A token is a run of characters with whitespace or a comment on either side, or anything
; between a pair of quotes. That is the whole of the lexical grammar: SplitBit assembly has
; no operators, no punctuation and no line continuation, so there is nothing here that has
; to look ahead more than one character.
;
; ONE CHARACTER OF LOOKAHEAD, and it is held here rather than in the reader. A word ends
; when something that is not part of it turns up, and that something has already been read
; by the time anyone knows - so it is put in TokPending and taken again next time. Keeping
; it at this level rather than pushing it back into the reader means the line number needs
; no arithmetic: srcNext counted the newline when it handed it out, and it stays counted.
;
; Zero means "nothing held", which is safe because a source file is text and a text file
; has no zero bytes in it. A file that did would be rejected as unassemblable long before
; the difference showed.
;
; Written by Anachronaut
#Program
; The next token, into TokText with a zero after it. Q is zero if there was one.
;
; TokLength is how long it is, TokString says whether it arrived in quotes, and TokLine is
; the line it STARTED on - captured before the token is read, because a token ending in a
; newline has already moved the reader on to the next line by the time it is finished.
tokNext:
SETD.0 TokHeld
LDA.0
BRA tokFresh
RSTA
STA.0
RSTA
RSTB
CCF
ADD ; The one that was handed back, exactly as it was.
RET
tokFresh:
RSTA
SETD.0 TokString
STA.0
tokSkip:
CALL tokGet
BNQ tokEnded
CALL tokIsSpace
BRQ tokSkip
SETD.0 TokChar
LDA.0
INIB 0x3B ; A semicolon starts a comment.
XOR
BNQ tokBegin
tokComment:
; ---- Thrown away by the reader, not read one character at a time ----
;
; This used to walk the comment through tokGet and srcNext, classifying every byte of it on
; the way to discarding it. Comments are most of what this assembler reads - 87 per cent of
; what a small program pulls in, once the libraries it includes are counted - so it was most
; of the work, and all of it thrown away.
;
; A pushed back character cannot be sitting there: this is only reached from tokSkip, which
; got the semicolon out of tokGet and did not put anything back.
CALL srcSkipComment
BNQ tokEnded
BRI tokSkip
tokBegin:
; Where it starts, for anything that has to complain about it later.
SETD.0 TokLine
SETD.2 SrcLine
CALL numSet
RSTA
SETD.0 TokLength
STA.0
SETD.0 TokText
SETD.1 TokPointer
STD.0.1
SETD.0 TokChar
LDA.0
INIB 0x22 ; A quote starts a string.
XOR
BNQ tokWord
INIA 0d1
SETD.0 TokString
STA.0
tokStringLoop:
CALL tokGet
BNQ tokDone ; The file ended inside a string. Take what there is; the
; classifier will have something to complain about.
SETD.0 TokChar
LDA.0
INIB 0x22
XOR
BRQ tokDone
CALL tokAppend
BRI tokStringLoop
tokWord:
CALL tokAppend
tokWordLoop:
CALL tokGet
BNQ tokDone
CALL tokIsSpace
BRQ tokHoldDone
SETD.0 TokChar
LDA.0
INIB 0x3B
XOR
BRQ tokHoldDone ; A comment butting straight up against a word ends it.
CALL tokAppend
BRI tokWordLoop
tokHoldDone:
; Whatever ended the word was not part of it, so it goes back to be looked at again.
SETD.0 TokChar
LDA.0
SETD.0 TokPending
STA.0
tokDone:
SETD.1 TokPointer
LDD.0.1
RSTA
STA.0 ; The zero that makes it a string.
RSTA
RSTB
CCF
ADD ; Q is zero: there was a token.
RET
tokEnded:
RSTA
SETD.0 TokLength
STA.0
SETD.0 TokText
STA.0
RSTA
INIB 0d1
CCF
ADD ; Q is not zero: the source is finished.
RET
; Puts the held character back into the file it came from, leaving nothing in hand.
;
; This is what #Include calls before the reader puts the current file aside. The character
; the tokenizer is holding was read from that file and has not been used, so it goes back
; into it; there is then nothing to carry across the include and nothing to hand back at a
; moment that might land in the middle of a word.
tokUnread:
SETD.0 TokPending
LDA.0
BRA tokUnreadDone
CALL srcStepBack
RSTA
SETD.0 TokPending
STA.0
tokUnreadDone:
RET
; Hands the token just read back, so that the next tokNext produces it again.
;
; ONE TOKEN, and only where nothing has changed it since. The Vector Segment needs it: a
; name there may be followed by a number, by a handler, or by the next line's name, and
; which it is cannot be known without looking. Do NOT use it after anything that alters
; TokText - a label definition with its colon written over would come back as a use of the
; name rather than as a definition of it.
tokBack:
INIA 0d1
SETD.0 TokHeld
STA.0
RET
; The next character, into TokChar. Q is zero if there was one. Takes the held one first.
tokGet:
SETD.0 TokPending
LDA.0
BRA tokGetFresh
SETD.0 TokChar
STA.0
RSTA
SETD.0 TokPending
STA.0
RSTA
RSTB
CCF
ADD
RET
tokGetFresh:
CALL srcNext
BNQ tokGetNone
SETD.0 SrcChar
LDA.0
SETD.0 TokChar
STA.0
RSTA
RSTB
CCF
ADD
RET
tokGetNone:
RSTA
INIB 0d1
CCF
ADD
RET
; Adds TokChar to the token being built, unless it is already as long as one may be.
;
; A token that runs over is truncated rather than refused, and the classifier refuses it
; afterwards: nothing 255 characters long is a valid mnemonic, literal or label, so the
; error that comes out names what was wrong with it rather than only how long it was.
tokAppend:
SETD.0 TokLength
LDA.0
INIB 0xFF
XOR
BRQ tokAppendFull
SETD.1 TokPointer
LDD.0.1
SETD.2 TokChar
LDA.2
STA.0
INCD.0
STD.0.1
SETD.0 TokLength
LDA.0
INCA
STA.0
tokAppendFull:
RET
; Q is zero if TokChar is whitespace: a space, or anything in the run from tab to carriage
; return, which is what the C library calls a space and what the other assembler uses.
tokIsSpace:
SETD.0 TokChar
LDA.0
INIB 0x20
XOR
BRQ tokSpaceYes
SETD.0 TokChar
LDA.0
INIB 0x09
CCF
SUB
BRC tokSpaceNo ; Below a tab.
SETD.0 TokChar
LDA.0
INIB 0x0E
CCF
SUB
BNC tokSpaceNo ; Past a carriage return.
tokSpaceYes:
RSTA
RSTB
CCF
ADD
RET
tokSpaceNo:
RSTA
INIB 0d1
CCF
ADD
RET
#Data
TokLine:
0x00 0x00
TokPointer:
0x00 0x00
TokLength:
0x00
TokString:
0x00
TokChar:
0x00
TokPending:
0x00
TokHeld:
0x00
; As long as a token may be, and one more for the zero. The other assembler stops at the
; same 255, and the limit is worth matching rather than choosing again.
TokText:
#Reserve 0d256
+177
View File
@@ -0,0 +1,177 @@
; The assembler's front end on its own, one line per token.
;
; Prints the line each token started on, what the token turned out to be, how many bytes
; it will come to, and the token itself between brackets so that whitespace at either end
; would show if any ever leaked in.
;
; WHAT A TOKEN IS is the part worth checking here rather than at the far end. A wrong
; classification does not produce a wrong byte in an obvious place - it produces a right
; looking program of the wrong length, with everything after it shifted, and by then the
; only symptom is that a label points at the middle of an instruction.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Wanted
INIB 0d23
SWI osArgument
SETD.0 Wanted
LDA.0
BRA nothingAsked
SETD.0 Wanted
CALL srcOpen
BNQ noFile
tokenLoop:
CALL tokNext
BNQ tokensDone
SETD.0 TokLine
LDA.0
INCD.0
LDB.0
SWI osPrintNumber
CALL clsToken
BNQ badToken
; Which of the six it turned out to be. The names are four characters and a space, so
; the columns line up without any counting.
SETD.0 TypeNames
SETD.2 ClsType
LDA.2
CALL nameOfType
SETD.1 NamePointer
LDD.0.1
SWI osPrintString
SETD.0 ClsLength
LDA.0
INCD.0
LDB.0
SWI osPrintNumber ; Sixteen bits: a string of 255 characters is 256 bytes long.
SETD.0 OpenMark
SWI osPrintString
SETD.0 TokText
SWI osPrintString
SETD.0 CloseMark
SWI osPrintString
BRI tokenLoop
badToken:
SETD.0 StoppedText
SWI osPrintString
SWI osExit
; The name of type A, out of a table of fixed width entries so that no pointer arithmetic
; is needed beyond a multiply by the width.
nameOfType:
SETD.0 TypeWidth
LDB.0
RSTA
SETD.0 NameLeft
STA.0
SETD.2 ClsType
LDA.2
SETD.0 NameOffset
CALL numZero
nameLoop:
SETD.2 ClsType
LDA.2
SETD.0 NameLeft
LDB.0
CCF
SUB
BRQ nameFound
SETD.0 TypeWidth
LDA.0
SETD.0 NameOffset
CALL numAddByte
SETD.0 NameLeft
LDA.0
INCA
STA.0
BRI nameLoop
nameFound:
; The answer is left in NamePointer rather than in DP0, because a RET puts Data Pointers
; 0 to 2 back as they were: a routine cannot hand back a pointer, only write one down.
SETD.0 TypeNames
SETD.1 NamePointer
STD.0.1
SETD.0 NamePointer
SETD.2 NameOffset
CALL numAdd
RET
tokensDone:
SETD.0 DoneText
SWI osPrintString
SWI osExit
nothingAsked:
SETD.0 AskText
SWI osPrintString
SWI osExit
noFile:
SETD.0 NoFileText
SWI osPrintString
SWI osExit
#Data
#Base 0x3000
Wanted:
#Reserve 0d23
OpenMark:
" ["
CloseMark:
"]
"
; Six names of eleven characters each, counting the zero the assembler puts on the end of
; every string. Written one to a line so that adding a type is adding a line.
TypeNames:
" keyword "
" instr "
" value "
" string "
" label: "
" label "
TypeWidth:
0d11
NameLeft:
0x00
NameOffset:
0x00 0x00
NamePointer:
0x00 0x00
StoppedText:
"---- stopped: the assembler does not understand that
"
DoneText:
"---- no more tokens
"
AskText:
"say which file
"
NoFileText:
"no such file
"
#Include scratch.asm
#Include numbers.asm
#Include source.asm
#Include token.asm
#Include classify.asm
#Include table.asm
+294
View File
@@ -0,0 +1,294 @@
; The names in the Vector Segment, and what numbers they have.
;
; A vector name is not a label and the two are kept deliberately apart, so a program may
; call a routine `announce` and name a vector `announce` without either shadowing the
; other. They are looked up in different places because they mean different things: a label
; is an address and a vector is a number.
;
; FIXED FIELDS HERE, unlike the label table's arena. There are at most a couple of hundred
; of these against several hundred labels, and the names are short, so packing them would
; cost more code than it saved. Twenty eight bytes an entry:
;
; 0 23 the name, up to twenty two characters and a zero
; 23 1 the vector number
; 24 1 which table: 0 software, 1 hardware
; 25 2 the handler's address
; 27 1 whether a handler has been supplied
;
; A DECLARATION AND AN IMPLEMENTATION ARE THE SAME ENTRY. services.asm says a service is
; called osPrintString and has number 16; cosmos.asm says osPrintString is handled by
; handlePrintString. Both sides include the first file, so the name is met twice, and the
; second time fills in the handler rather than making a second entry. That is what lets one
; shared file serve a program that calls a service and the system that implements it.
;
; THE ORDER OF THIS TABLE IS THE ORDER OF THE OUTPUT FILE, and the order is declaration
; order rather than implementation order, because that is where the entry was made. It has
; to match what the other assembler does byte for byte.
;
; Numbers come from two places. A pinned one is written down in the source, and that is how
; anything two separately assembled programs must agree about is fixed - the system's
; services are all pinned. Everything else is numbered automatically from 64 up, out of a
; range nothing outside one program can name, so what number it gets cannot matter.
;
; Written by Anachronaut
#Program
vecReset:
SETD.0 VecCount
CALL numZero
INIA 0d64
SETD.0 VecNextAuto
STA.0
RET
; Declares the name at DP0 with the number in A, in the table VecPutBase names. Q is zero
; if it went in.
vecDeclare:
SETD.2 VecPutNumber
STA.2
SETD.2 VecSubject
STD.0.2
CALL vecFind
BNQ vecDeclareFresh
SETD.0 VecTwice
CALL clsComplain
BRI vecDeclareNo
vecDeclareFresh:
SETD.0 VecCount
SETD.2 VecLimit
CALL numCompare
BNC vecDeclareFull
SETD.0 VecWhich
SETD.2 VecCount
CALL numSet
CALL vecSlotAt
SETD.1 VecSubject
LDD.0.1
SETD.1 VecSlot
LDD.1.1
CALL srcKeepName
SETD.1 VecSlot
LDD.0.1
INIA 0d23
SETD.0 VecSlot
CALL numAddByte
SETD.1 VecSlot
LDD.0.1
SETD.2 VecPutNumber
LDA.2
STA.0
INCD.0
SETD.2 VecPutBase
LDA.2
STA.0 ; Which table it lives in.
INCD.0
RSTA
STA.0
INCD.0
STA.0
INCD.0
STA.0 ; No handler yet, and no address to go with one.
SETD.0 VecCount
CALL numStep
RSTA
RSTB
CCF
ADD
RET
vecDeclareFull:
SETD.0 VecFull
CALL clsComplain
vecDeclareNo:
RSTA
INIB 0d1
CCF
ADD
RET
; The next number nothing has taken, into VecPutNumber. These start at 64, above everything
; that may be pinned, so a name a program made up for itself can never land on a system
; service.
;
; Into memory rather than into A, because a CALL puts A back as it found it.
vecTakeAuto:
SETD.0 VecNextAuto
LDA.0
SETD.0 VecPutNumber
STA.0
SETD.0 VecNextAuto
LDA.0
INCA
STA.0
RET
; Looks up the name at DP0. Q is zero if it is there, and then VecNumber is its number.
vecFind:
SETD.2 VecSought
STD.0.2
SETD.0 VecWhich
CALL numZero
vecFindLoop:
SETD.0 VecWhich
SETD.2 VecCount
CALL numCompare
BNC vecFindMissing
CALL vecSlotAt
SETD.1 VecSlot
LDD.0.1
SETD.1 VecSought
LDD.1.1
CALL sameText
BRQ vecFindGot
SETD.0 VecWhich
CALL numStep
BRI vecFindLoop
vecFindGot:
CALL vecReadFields
RSTA
RSTB
CCF
ADD
RET
vecFindMissing:
RSTA
INIB 0d1
CCF
ADD
RET
; Everything the entry at VecSlot says, into VecNumber, VecBase, VecHandler and
; VecHasHandler. VecSlot is left pointing past the name, at the number.
vecReadFields:
INIA 0d23
SETD.0 VecSlot
CALL numAddByte
SETD.1 VecSlot
LDD.0.1
LDA.0
SETD.1 VecNumber
STA.1
INCD.0
LDA.0
SETD.1 VecBase
STA.1
INCD.0
LDA.0
SETD.1 VecHandler
STA.1
INCD.0
LDA.0
INCD.1
STA.1
INCD.0
LDA.0
SETD.1 VecHasHandler
STA.1
RET
; Gives the entry at VecSlot the handler in VecPutHandler. VecSlot must already have been
; walked past the name by vecReadFields, which is how it is always reached.
;
; A VARIABLE OF ITS OWN, not VecHandler, and that is not tidiness. Finding the entry to
; write to means calling vecFind, which reads the entry's fields out - including the
; handler it does not have yet. An address resolved into VecHandler before the find was
; overwritten with zero by the find itself, and the file came out with a vector pointing
; at address zero: a slot that looked installed and went nowhere.
vecWriteHandler:
SETD.1 VecSlot
LDD.0.1
INCD.0
INCD.0
SETD.2 VecPutHandler
LDA.2
STA.0
INCD.0
INCD.2
LDA.2
STA.0
INCD.0
INIA 0d1
STA.0
RET
; Where entry number VecWhich sits, into VecSlot. Twenty eight bytes an entry.
vecSlotAt:
SETD.0 VecSlot
SETD.2 ScratchVecNames
CALL numSet
SETD.0 VecSlotLeft
SETD.2 VecWhich
CALL numSet
vecSlotLoop:
SETD.0 VecSlotLeft
LDA.0
INCD.0
LDB.0
OR
BRQ vecSlotDone
INIA 0d28
SETD.0 VecSlot
CALL numAddByte
SETD.0 VecSlotLeft
SETD.2 VecOne
CALL numTake
BRI vecSlotLoop
vecSlotDone:
RET
#Data
VecCount:
0x00 0x00
VecWhich:
0x00 0x00
VecSlot:
0x00 0x00
VecSlotLeft:
0x00 0x00
VecSought:
0x00 0x00
VecSubject:
0x00 0x00
VecNumber:
0x00
VecPutNumber:
0x00
VecPutBase:
0x00
VecHandler:
0x00 0x00
VecPutHandler:
0x00 0x00
VecHasHandler:
0x00
VecBase:
0x00
VecNextAuto:
0x00
VecOne:
0x00 0x01
; Sixty four names, which is every number a program may name for itself.
VecLimit:
0x00 0x40
VecTwice:
"that vector name is declared twice"
VecFull:
"too many vector names"
VecName:
#Reserve 0d23
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
Hello, SplitBit!
+16
View File
@@ -0,0 +1,16 @@
#! script
; The launcher for Lunar Porter, which is what typing "Lander" actually starts.
;
; An application with things of its own gets a DIRECTORY of its own, and a typed word names
; a file rather than a directory - so what sits in /Apps is this, and the game sits with its
; tune in /Packages/app.Lander. The shell decides what to run by reading it, so this file
; beginning "#!" and Say.sbx beginning "SBEX" go down the same road and neither the shell nor
; the person has to know which kind of thing they started.
;
; That indirection is the point of it. Where the game physically lives stops being part of
; its name, so an installer may put it on another disk and change only this line.
;
; #quiet, because a launcher is machinery and not narration. Without it, starting a game
; prints the path of the thing it is about to start.
#quiet
0:/Packages/app.Lander/Lander.sbx $args
+1814 -59
View File
File diff suppressed because one or more lines are too long
+469
View File
@@ -0,0 +1,469 @@
; config.asm
; Reading a configuration file.
;
; One setting to a line: a key, a space, and the rest of the line is the value. A semicolon
; starts a comment and a blank line is nothing. That format was not designed so much as
; noticed - textSplit already cuts the first word off a line and leaves the rest, and
; textSame already compares two strings and insists they end together, so reading a setting
; is those two routines and a loop. It is also the shape the shell reads, which means a
; configuration line is a command line the machine reads instead of a person typing one.
;
; ---- Configuration is advice ----
;
; A file that is not there, a key that is not in it, and a value that makes no sense are
; all the same answer: use the default. Nothing here reports a failure for any of them,
; because a program that cannot run without its configuration has turned its configuration
; into a single point of failure - and for the thing that starts the machine, that would
; mean a mistyped file is a machine that does not start.
;
; ---- But quiet is not the same as silent ----
;
; A setting that was meant and did not take effect should say so, or the only symptom is
; that the machine did not do what somebody asked it to. So there are TWO routines rather
; than one: cfgGet reads, and never says anything; cfgCheck reads the whole file once and
; reports what it did not understand.
;
; They are separate because they know different things. A malformed line is malformed to
; anybody, so cfgGet's own scan could spot one - but reading three settings would then
; report the same bad line three times. And an unknown KEY is not something this can judge
; at all: only the caller knows which keys mean anything to it, which is why cfgCheck is
; given a table of them.
;
; Written by Anachronaut
#Program
; ---- Reading the file in ----
;
; DP0 names a path, DP1 names a buffer, and A is how many blocks the buffer holds.
;
; Q is zero if there is something to read, INCLUDING WHEN THERE IS NO FILE. A missing
; configuration file is a file with no settings in it, which is a perfectly ordinary thing
; for a disk to have, and the caller wanting the defaults gets them either way.
cfgLoad:
SETD.2 CfgRoom
STA.2
SETD.2 CfgBufferAt
STD.1.2
; Nothing loaded until something is.
RSTA
SETD.2 CfgLength
STA.2
INCD.2
STA.2
CALL sbfsFind
BNQ cfgNoFile
; A file too big for the buffer is read as far as it fits rather than refused: the
; settings at the top still work, and the ones past the end are missing keys, which is
; a case every caller already handles.
SETD.2 SbfsFileBlocks
INCD.2
LDA.2
SETD.2 CfgRoom
LDB.2
CCF
SUB
BNC cfgTooBig
SETD.2 CfgBufferAt
LDD.1.2
CALL sbfsRead
BNQ cfgNoFile
; How many bytes of it are real. A file is whole blocks and then a tail, which is the
; block count in the high byte and the tail in the low one.
SETD.2 SbfsFileBlocks
INCD.2
LDA.2
SETD.2 CfgLength
STA.2
SETD.2 SbfsFileTail
LDA.2
SETD.2 CfgLength
INCD.2
STA.2
cfgReady:
RSTA
RSTB
CCF
ADD
RET
cfgTooBig:
; As much of it as there is room for, which is every whole block of the buffer.
SETD.2 CfgRoom
LDA.2
SETD.2 SbfsFileBlocks
INCD.2
STA.2
RSTA
SETD.2 SbfsFileTail
STA.2
SETD.2 CfgBufferAt
LDD.1.2
CALL sbfsRead
BNQ cfgNoFile
SETD.2 CfgRoom
LDA.2
SETD.2 CfgLength
STA.2
RSTA
INCD.2
STA.2
BRI cfgReady
cfgNoFile:
; No file, or a disk that would not give it up. Neither is a failure: it is a file with
; nothing in it, and every key will be missing, which is what a default is for.
RSTA
SETD.2 CfgLength
STA.2
INCD.2
STA.2
BRI cfgReady
; ---- Walking it a line at a time ----
;
; cfgRewind puts the walk back at the top. cfgLine copies the next line into CfgLine and
; leaves Q zero if there was one.
;
; A line longer than the buffer holds is copied as far as it goes and the rest of it is
; skipped, with CfgLong set to say so. Ignoring it is what the format asks for; saying that
; it was ignored is what cfgCheck is for.
cfgRewind:
SETD.0 CfgBufferAt
SETD.1 CfgWalk
CALL sbfsCopyWord
SETD.0 CfgLength
SETD.1 CfgLeft
CALL sbfsCopyWord
RET
cfgLine:
RSTA
SETD.0 CfgLong
STA.0
SETD.0 CfgFill
STA.0
SETD.0 CfgLeft
LDA.0
INCD.0
LDB.0
OR
BRQ cfgLineNone
SETD.2 CfgWalk
LDD.0.2
SETD.1 CfgLine
cfgLineLoop:
SETD.2 CfgLeft
LDA.2
INCD.2
LDB.2
OR
BRQ cfgLineEnd
LDA.0
INIB 0x0A
CCF
SUB
BRQ cfgLineBreak
; Room for it, or the line is one of the long ones and only its beginning is kept.
SETD.2 CfgFill
LDB.2
INIA 0d128
CCF
SUB
BRQ cfgLineOverflow
LDA.0
STA.1
INCD.1
LDA.2
INCA
STA.2
cfgLineStep:
INCD.0
RCAL cfgSpent
BRI cfgLineLoop
cfgLineOverflow:
INIA 0x01
SETD.2 CfgLong
STA.2
BRI cfgLineStep
cfgLineBreak:
INCD.0
RCAL cfgSpent
cfgLineEnd:
RSTA
STA.1 ; The zero that makes what was copied a string.
SETD.2 CfgWalk
STD.0.2
RSTA
RSTB
CCF
ADD
RET
cfgLineNone:
RSTA
INIB 0d1
CCF
ADD
RET
; One byte of the file accounted for.
cfgSpent:
SETD.2 CfgLeft
INCD.2
LDA.2
BNA cfgSpentLow
SETD.2 CfgLeft
LDA.2
DECA
STA.2
INCD.2
INIA 0xFF
STA.2
RRET
cfgSpentLow:
DECA
STA.2
RRET
; ---- What a line turns out to be ----
;
; Q is zero if there is a setting on it, and then CfgLine names the key and CfgValue names
; the value. Q is not zero for a blank line or a comment, which are not settings and are
; not mistakes either.
cfgParse:
RSTA
SETD.0 CfgNoValue
STA.0
; A LINE THAT WAS CUT SHORT IS NOT A SETTING. Its key may look perfectly good and its
; value is whatever fitted, so treating it as one would hand back an answer that is wrong
; rather than missing - and a missing setting gets the default, which is the safe thing.
; Reported by cfgCheck, ignored by everything.
SETD.0 CfgLong
LDA.0
BNA cfgParseNothing
SETD.0 CfgLine
LDA.0
BRA cfgParseNothing
INIB 0x3B ; A semicolon starts a comment.
CCF
SUB
BRQ cfgParseNothing
CALL textSplit
SETD.0 TextRest
SETD.1 CfgValue
CALL sbfsCopyWord
; A KEY WITH NOTHING AFTER IT IS NOT A SETTING, and finding that out here rather than in
; each caller is what makes first-match-wins safe. A file with
;
; system
; system /System/Boot/cosmos.bin
;
; in it would otherwise match the first line, hand back an empty value, and the machine
; would try to start a file with no name while a perfectly good setting sat underneath.
; An unusable value is an absent one, which is what C3 says and where it earns its keep.
SETD.2 CfgValue
LDD.0.2
LDA.0
BRA cfgParseNoValue
RSTA
RSTB
CCF
ADD
RET
cfgParseNoValue:
; Told apart from a blank line, because one of them is a mistake and the other is not.
INIA 0x01
SETD.0 CfgNoValue
STA.0
cfgParseNothing:
RSTA
INIB 0d1
CCF
ADD
RET
; ---- Asking for one setting ----
;
; DP0 names the key. Q is zero if the file had it, and CfgValue then names the value.
; Says nothing about anything, ever.
cfgGet:
SETD.1 CfgWanted
STD.0.1
CALL cfgRewind
cfgGetLoop:
CALL cfgLine
BNQ cfgGetMissing
CALL cfgParse
BNQ cfgGetLoop
SETD.0 CfgLine
SETD.2 CfgWanted
LDD.1.2
CALL textSame
BNQ cfgGetLoop
RSTA
RSTB
CCF
ADD
RET
cfgGetMissing:
RSTA
INIB 0d1
CCF
ADD
RET
; ---- Saying what was not understood ----
;
; DP0 names a table of the keys the caller knows: strings one after another, ended by an
; empty one. Every line is looked at once, and anything that will not take effect is
; reported with enough of itself to be found and fixed.
;
; Nothing here changes what any setting does. The defaults have already been used by the
; time this runs, or will be; this exists so that a setting somebody meant, which did not
; happen, does not do so in silence.
cfgCheck:
SETD.1 CfgKnown
STD.0.1
CALL cfgRewind
cfgCheckLoop:
CALL cfgLine
BNQ cfgCheckDone
SETD.0 CfgLong
LDA.0
BRA cfgCheckParse
SETD.0 CfgLongText
RCAL cfgSay
SETD.0 CfgLine
RCAL cfgSay
RCAL cfgNewLine
BRI cfgCheckLoop ; Said once. What its key looks like is not worth a second remark.
cfgCheckParse:
CALL cfgParse
BRQ cfgCheckKnown
; Not a setting. A blank line or a comment is not a mistake and gets no remark; a key
; somebody started and did not finish is, and gets one.
SETD.0 CfgNoValue
LDA.0
BRA cfgCheckLoop
SETD.0 CfgEmptyText
RCAL cfgSay
SETD.0 CfgLine
RCAL cfgSay
RCAL cfgNewLine
BRI cfgCheckLoop
cfgCheckKnown:
SETD.2 CfgKnown
LDD.1.2
cfgCheckNext:
LDA.1
BRA cfgCheckUnknown ; The empty name that ends the table.
SETD.0 CfgLine
CALL textSame
BRQ cfgCheckLoop ; A key this caller knows, so there is nothing to say.
; Past this name and on to the next one.
cfgCheckSkip:
LDA.1
BRA cfgCheckSkipped
INCD.1
BRI cfgCheckSkip
cfgCheckSkipped:
INCD.1
BRI cfgCheckNext
cfgCheckUnknown:
SETD.0 CfgUnknownText
RCAL cfgSay
SETD.0 CfgLine
RCAL cfgSay
RCAL cfgNewLine
BRI cfgCheckLoop
cfgCheckDone:
RET
; ---- Saying things ----
;
; Its own rather than the console's, because the first thing to read a configuration file
; is the boot loader and everything it uses has to fit in a boot slot.
cfgSay:
LDA.0
BRA cfgSaid
OUTA 0x00
INCD.0
BRI cfgSay
cfgSaid:
RRET
cfgNewLine:
INIA 0x0A
OUTA 0x00
RRET
#Data
CfgLongText:
"a line too long to read: "
CfgEmptyText:
"a setting with no value: "
CfgUnknownText:
"a setting nothing asked for: "
CfgBufferAt:
0x00 0x00
CfgRoom:
0x00
CfgLength:
0x00 0x00
CfgWalk:
0x00 0x00
CfgLeft:
0x00 0x00
CfgFill:
0x00
CfgLong:
0x00
CfgNoValue:
0x00
CfgWanted:
0x00 0x00
CfgKnown:
0x00 0x00
CfgValue:
0x00 0x00
; One line, and the number is the one the format says: a key of twenty two and a path of
; the length a path is allowed to be leaves room to spare in a hundred and twenty eight.
CfgLine:
#Reserve 0d129
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
; Read a named file from beginning to end, one block at a time.
;
; DP0 = filename
; CALL fileStreamOpen Q = 0, or the osFileInfo error
; CALL fileStreamNext Q = 0, or the osFileBlock error
; DP3 = bytes in FileStreamBlock; zero means EOF
;
; This is application-side machinery built on CosmOS services, not a filesystem and not
; yet an OS stream. Open remembers the address of the caller's filename, which must remain
; valid until the one stream is finished.
;
; Written by ChatGPT for Anachronaut's SplitBit
#Program
fileStreamOpen:
SETD.3 FileStreamName
STD.0.3
SWI osFileInfo
BNQ fileStreamReturn
PSHD.3
POPB
POPA
SETD.3 FileStreamBlocks
STA.3
INCD.3
STB.3
SETD.3 FileStreamIndex
RSTA
STA.3
INCD.3
STA.3
fileStreamReturn:
RET
fileStreamNext:
SETD.3 FileStreamBlocks
LDA.3
INCD.3
LDB.3
OR
BRQ fileStreamEnd
SETD.3 FileStreamName
LDD.0.3
SETD.1 FileStreamBlock
SETD.3 FileStreamIndex
LDA.3
INCD.3
LDB.3
SWI osFileBlock
BNQ fileStreamReturn
; DP3 is the service's return value and also the pointer this routine uses for all of
; its bookkeeping. Keep the value before touching the pointer, then restore it at the
; one successful return below.
PSHD.3
POPB
POPA
SETD.3 FileStreamCount
STA.3
INCD.3
STB.3
; Index++.
SETD.3 FileStreamIndex
INCD.3
LDA.3
INCA
STA.3
BNC fileStreamTakeBlock
DECD.3
LDA.3
INCA
STA.3
fileStreamTakeBlock:
; Blocks--.
SETD.3 FileStreamBlocks
INCD.3
LDA.3
BRA fileStreamBlocksBorrow
DECA
STA.3
BRI fileStreamReadReturn
fileStreamBlocksBorrow:
INIA 0xFF
STA.3
DECD.3
LDA.3
DECA
STA.3
fileStreamReadReturn:
SETD.3 FileStreamCount
LDD.3.3
RET
fileStreamEnd:
; There is no reset-pointer instruction. Two zero bytes through the Stack are the
; literal construction of a zero Data Pointer.
RSTA
PSHA
PSHA
POPD.3
RET
; DP2 names a big-endian sixteen-bit byte count. Decrement it, returning Q = 0 when it
; reached zero and nonzero while bytes remain. This is shared because correctly counting
; a full 0x0100-byte block is the least interesting part of both applications to duplicate.
fileStreamTakeRemaining:
INCD.2
LDA.2
BRA fileStreamRemainingBorrow
DECA
STA.2
DECD.2
LDB.2
INCD.2
LDA.2
OR
RET
fileStreamRemainingBorrow:
INIA 0xFF
STA.2
DECD.2
LDA.2
DECA
STA.2
LDB.2
INCD.2
LDA.2
OR
RET
#Data
FileStreamName:
0x00 0x00
FileStreamBlocks:
0x00 0x00
FileStreamIndex:
0x00 0x00
FileStreamCount:
0x00 0x00
FileStreamBlock:
#Reserve 0d256
File diff suppressed because it is too large Load Diff
+675
View File
@@ -0,0 +1,675 @@
; Reading shell lines out of a file.
;
; DP0 = the file's name
; CALL scriptOpen Q = 0 and a script is running, or Q says what was wrong:
; 1 there is no such file
; 2 it is not a script - no #! on the front
; 3 too many scripts inside each other
; DP0 = a buffer, B = how much room
; CALL scriptLine Q = 0 and there is a line in the buffer, or nonzero at the end
;
; Written by Anachronaut
;
; ---- Why the shell reads files and not the other way round ----
;
; fileStream.asm does almost this and is deliberately not used. It is application machinery:
; More, Type and Wander each include it and each get their own copy in their own Data
; Segment. The shell reading a script through it would be a fourth copy, and the shell is the
; one place where that copy has to survive running a program - which is exactly the thing
; those programs are. Here the state belongs to the system and nothing a program does can
; reach it.
;
; ---- A block, and a nought on the end of it ----
;
; The reader walks a Data Pointer along the block and stops at a nought. A block is 256 bytes
; and a count of them does not fit in a byte, so every other way of knowing where the block
; ends costs sixteen bit arithmetic on every character. Writing a nought after the last byte
; costs one store per block and turns the whole question into "is this byte zero".
;
; The buffer is 257 bytes for that reason: a full block leaves the nought at the end of it.
; A script cannot contain a nought, which is not a restriction anybody will notice - it is
; text, and the #! on the front is what stops a program being read as one.
#Program
; ---- One script inside another ----
;
; A build script calling a setup script is the first thing anybody tries, so what is saved
; when one script starts another is a POSITION AND NOT A BUFFER. The whole state of a
; running script is its name, which block comes next, how many are left, and where in the
; block it is - seventy bytes, laid out next to each other below so that saving it is one
; copy. The block itself is read again on the way back, which costs one disk read per return
; and saves 257 bytes a level.
;
; Four levels. Deep enough for a script calling a script that calls a helper, and shallow
; enough that a script which runs itself says so instead of filling memory.
scriptPush:
CALL scriptSlotAt
SETD.0 ScriptName
PSHD.3
POPD.1
CALL scriptCopyState
RET
scriptPop:
CALL scriptSlotAt
PSHD.3
POPD.0
SETD.1 ScriptName
CALL scriptCopyState
; ScriptAt points into the block buffer, which now holds somebody else's block. Reading
; it back is what makes the saved pointer mean what it meant.
CALL scriptReread
RET
; DP3 = where the script one level up is remembered. Reached by stepping rather than by
; multiplying, because this machine cannot multiply and the depth is never more than three
; steps. DP3 because RET puts the others back.
scriptSlotAt:
SETD.3 ScriptSaved
SETD.2 ScriptDepth
LDA.2
DECA
BRA scriptSlotDone
scriptSlotStep:
DPUP.3 0d205
DECA
BNA scriptSlotStep
scriptSlotDone:
RET
; Seventy bytes, DP0 to DP1.
scriptCopyState:
INIB 0d205
scriptCopyByte:
LDA.0
STA.1
INCD.0
INCD.1
DECB
BNB scriptCopyByte
RET
; The block that is meant to be in the buffer, back in the buffer. ScriptIndex is the NEXT
; one, so the one being read from is the one before it.
scriptReread:
SETD.1 ScriptIndex
LDA.1
INCD.1
LDB.1
DECB
BNC scriptRereadGo
DECA
scriptRereadGo:
SETD.0 ScriptName
SETD.1 ScriptBlock
SWI osFileBlock
SETD.1 ScriptBlock
PSHD.3
POPB
POPA
DPUW.1
RSTA
STA.1
RET
; ---- Opening ----
;
; The name is COPIED rather than remembered by address. osFileBlock is given the name again
; for every block, and the caller's copy is CommandLine, which the next line typed will
; overwrite. fileStream remembers an address and says in its own comment that the address
; must stay valid; here it cannot, because the thing that reads the next line is the reason
; the name is needed.
scriptOpen:
; ---- Four deep and no further ----
SETD.1 ScriptDepth
LDA.1
INIB 0d4
CCF
SUB
BRQ scriptOpenTooDeep
; ---- The one already running is put somewhere safe FIRST ----
;
; Before anything below overwrites it, and put back again on every way out of here that is
; not success. Opening writes the name into the live state to ask the disk about it, so by
; the time the answer is known the caller's place is already gone.
;
; A still holds the depth from the check above: SUB writes Q and leaves it alone.
BRA scriptOpenOutermost
CALL scriptPush
BRI scriptOpenFirst
scriptOpenOutermost:
; ---- The one place a stale quiet would matter ----
;
; A script started from the prompt begins loud whatever the last one left behind, which is
; what makes this the only place the flag has to be put back. Nested scripts INHERIT
; instead: a build that asked for quiet meant its helpers too.
RSTA
SETD.1 ScriptQuiet
STA.1
scriptOpenFirst:
SETD.1 ScriptName
INIB 0d63
CALL copyText
; ---- And what it was given ----
;
; After the push, so that what the script one level up was given is already somewhere safe.
; A script started with nothing gets an EMPTY one rather than none at all, which is what
; makes "$args" always mean something inside a script while "$1" can be missing.
SETD.1 ScriptArgsFrom
LDD.0.1
SETD.1 ScriptArgs
INIB 0d128
CALL copyText
CALL scriptKeepDrive
SETD.0 ScriptName
SWI osFileInfo
CALL scriptGiveDriveBack
BRQ scriptOpenThere
INIA 0x01
BRI scriptOpenFailed
scriptOpenThere:
; DP3 is how many blocks. A file of none is not a script.
PSHD.3
POPB
POPA
SETD.1 ScriptBlocks
STA.1
INCD.1
STB.1
OR
BRQ scriptOpenNotOne
SETD.1 ScriptIndex
RSTA
STA.1
INCD.1
STA.1
; The first block, so that the #! can be looked at before anything is promised.
CALL scriptFill
BNQ scriptOpenNotOne
; ---- What makes a file a script ----
;
; Two bytes, and the rest of the line ignored. A directive rather than a comment, the way
; #Program is in assembly: the shell reads it and refuses the file without it, so calling
; it a comment would be a lie about what it does. What follows the #! is where the name of
; an interpreter goes when there is a second one; today there is one and it is this shell.
SETD.1 ScriptBlock
LDA.1
INIB 0d35 ; '#'
CCF
SUB
BNQ scriptOpenNotOne
INCD.1
LDA.1
INIB 0d33 ; '!'
CCF
SUB
BNQ scriptOpenNotOne
; Past the shebang line, wherever it ends.
CALL scriptSkipLine
SETD.1 ScriptDepth
LDA.1
INCA
STA.1
RSTA
BRI scriptOpenAnswer ; A is nought, which is the answer for "it opened".
scriptOpenTooDeep:
INIA 0x03
BRI scriptOpenAnswer ; Nothing was pushed, so there is nothing to put back.
scriptOpenNotOne:
INIA 0x02
scriptOpenFailed:
; Whatever was running is still running, and its place is in the slot rather than in the
; live state. A is the answer and must survive being put back.
SETD.1 ScriptDepth
LDB.1
BRB scriptOpenAnswer
PSHA
CALL scriptPop
POPA
scriptOpenAnswer:
; Q is the answer, and A holds it. Adding nought is how a register becomes Q.
RSTB
CCF
ADD
RET
; ---- One line ----
;
; Comments and blank lines never reach the shell. The reader drops them, so the echo does not
; print them and the dispatch never sees a line it would have to know to ignore.
scriptLine:
SETD.1 ScriptRoom
STB.1
SETD.1 ScriptInto
STD.0.1
scriptLineAgain:
; ---- Where this line begins ----
;
; Kept before it is read, because a loop has to be able to go back to the line that opened
; it and by the time that line has been read the reader is past it. Three words, and the
; block itself is read again on the way back, which is what scriptReread is for.
SETD.0 ScriptIndex
SETD.1 ScriptLineIndex
CALL sbfsCopyWord
SETD.0 ScriptAt
SETD.1 ScriptLineAt
CALL sbfsCopyWord
SETD.0 ScriptBlocks
SETD.1 ScriptLineBlocks
CALL sbfsCopyWord
SETD.1 ScriptLength
RSTA
STA.1
SETD.0 ScriptInto
LDD.0.0
scriptLineNext:
CALL scriptByte
BNQ scriptLineEnded
SETD.1 ScriptChar
LDA.1
INIB 0d10
CCF
SUB
BRQ scriptLineDone
; A still holds the character: SUB writes Q and leaves it alone.
INIB 0d13 ; Carriage return, from a file written on a host that uses them.
CCF
SUB
BRQ scriptLineNext
; Room?
SETD.1 ScriptLength
LDA.1
SETD.1 ScriptRoom
LDB.1
CCF
SUB
BRQ scriptLineNext ; Full. Read on and drop what comes, the way readLine does.
SETD.1 ScriptChar
LDA.1
STA.0
INCD.0
SETD.1 ScriptLength
LDA.1
INCA
STA.1
BRI scriptLineNext
scriptLineDone:
; Terminate it, then decide whether the shell wants to see it.
RSTA
STA.0
SETD.0 ScriptInto
LDD.0.0
LDA.0
BRA scriptLineAgain ; Empty.
INIB 0d59 ; ';' - a comment, the same as everywhere else on this machine.
CCF
SUB
BRQ scriptLineAgain
; ---- A directive, which is about the file rather than for the shell ----
;
; '#' the way the assembler means it. #quiet stops each line being echoed as it runs, for a
; script whose own output is the point and which the prompts get in the way of; #loud puts
; it back.
;
; ANYTHING ELSE BEGINNING WITH # IS HANDED TO THE SHELL, which does not know it and says
; so and stops the script. That is deliberate and it is free: a script that asked for
; something this shell cannot do should not carry on as though it had been given it, and
; the machinery for saying so already exists.
INIB 0d35 ; '#'
CCF
SUB
BNQ scriptLineGive
SETD.1 QuietWord
CALL textSame
BRQ scriptLineQuiet
SETD.0 ScriptInto
LDD.0.0
SETD.1 LoudWord
CALL textSame
BRQ scriptLineLoud
SETD.0 ScriptInto
LDD.0.0
BRI scriptLineGive
scriptLineQuiet:
INIA 0x01
SETD.1 ScriptQuiet
STA.1
BRI scriptLineAgain
scriptLineLoud:
RSTA
SETD.1 ScriptQuiet
STA.1
BRI scriptLineAgain
scriptLineGive:
RSTA ; Q = 0: there is a line.
RSTB
CCF
ADD
RET
scriptLineEnded:
; ---- A last line with no newline on it is still a line ----
;
; Text files do not reliably end with one, and a script whose final command silently did
; not run because somebody's editor left the newline off is a bad way to find that out.
; If anything has been gathered, finish it the ordinary way; the next call comes back here
; with nothing gathered and ends for real.
SETD.1 ScriptLength
LDA.1
BRA scriptLineNoMore
BRI scriptLineDone
scriptLineNoMore:
CALL scriptClose
INIA 0x01
RSTB
CCF
ADD
RET
; ---- One character, or the end ----
;
; Q = 0 and the character is in ScriptChar, or Q is one and there are no more.
;
; IN MEMORY RATHER THAN IN A REGISTER, because RET puts A and B back the way the caller had
; them - only Q and Data Pointer 3 survive a CALL. Handing the character back in A looked
; right, assembled, and returned the caller's own A every time.
scriptByte:
SETD.1 ScriptAt
LDD.1.1
LDA.1
BRA scriptByteRefill ; The nought at the end of the block.
; Step the saved pointer past it.
SETD.1 ScriptChar
STA.1
SETD.1 ScriptAt
LDD.0.1
INCD.0
STD.0.1
RSTA
RSTB
CCF
ADD ; Q = 0.
RET
scriptByteRefill:
CALL scriptFill
BNQ scriptByteNoMore
BRI scriptByte
scriptByteNoMore:
INIA 0x01
RSTB
CCF
ADD
RET
; ---- The next block, with a nought written after it ----
;
; Q = 0 if there is one.
scriptFill:
SETD.1 ScriptBlocks
LDA.1
INCD.1
LDB.1
OR
BRQ scriptFillNoMore
CALL scriptKeepDrive
SETD.0 ScriptName
SETD.1 ScriptBlock
SETD.2 ScriptIndex
LDA.2
INCD.2
LDB.2
SWI osFileBlock
CALL scriptGiveDriveBack
BNQ scriptFillNoMore
; DP3 is how many bytes came back. The nought goes after them.
SETD.1 ScriptBlock
PSHD.3
POPB
POPA
DPUW.1
RSTA
STA.1
SETD.1 ScriptAt
SETD.0 ScriptBlock
STD.0.1
; Index++, blocks--.
SETD.1 ScriptIndex
INCD.1
LDA.1
INCA
STA.1
BNC scriptFillCount
DECD.1
LDA.1
INCA
STA.1
scriptFillCount:
SETD.1 ScriptBlocks
INCD.1
LDA.1
BRA scriptFillBorrow
DECA
STA.1
BRI scriptFillGot
scriptFillBorrow:
INIA 0xFF
STA.1
DECD.1
LDA.1
DECA
STA.1
scriptFillGot:
RSTA
RSTB
CCF
ADD
RET
scriptFillNoMore:
INIA 0x01
RSTB
CCF
ADD
RET
; ---- Reading a script must not move the person ----
;
; The name is resolved afresh for every block, and a name with a drive in front of it moves
; the machine to that drive as a side effect of being resolved - sbfsWalk calls sbfsUse on
; the way past. So a script found in the system's place on drive 0, started by somebody
; standing on a disk of their own, would run its lines on the system disk instead of theirs.
;
; It matters more now than it used to. `do 0:/Apps/setup.sh` was always able to do this;
; typing a name now finds scripts the same three places a program is looked for, and the
; third of them is drive-qualified.
;
; The answer that survives the CALL: Q into A and the byte count onto the stack, because
; sbfsUse writes Q and RET puts DP3 back the way it found it.
scriptKeepDrive:
INA 0x24
SETD.1 ScriptDrive
STA.1
RET
scriptGiveDriveBack:
PSHD.3
MVQA
PSHA
SETD.1 ScriptDrive
LDA.1
CALL sbfsUse
POPA
POPD.3
; A holds what Q held. Adding nought to it is how it becomes Q again.
RSTB
CCF
ADD
RET
; Everything up to and including the next line feed, thrown away. Used for the shebang.
scriptSkipLine:
CALL scriptByte
BNQ scriptSkipDone
SETD.1 ScriptChar
LDA.1
INIB 0d10
CCF
SUB
BNQ scriptSkipLine
scriptSkipDone:
RET
; One script ending. Whatever asked for it carries on, if anything did.
scriptClose:
SETD.1 ScriptDepth
LDA.1
BRA scriptCloseNone
DECA
STA.1
BRA scriptCloseNone
CALL scriptPop
RET
scriptCloseNone:
; ---- Nothing to put back here ----
;
; The flag is only ever READ while a script is running: the console's own path says its
; prompt whatever this holds, and the first script started from the prompt sets it to loud
; on the way in. So a stale quiet cannot be observed, and clearing it here would be a line
; no test could tell the difference about - which is how it was written the first time, with
; a comment claiming it fixed something. It fixed something that a later change had already
; made impossible.
RET
; Every script ending at once, which is what a line that did not work means. A build whose
; helper failed should not carry on in the script that called the helper either.
scriptAbandon:
RSTA
SETD.1 ScriptDepth
STA.1
RET
#Data
QuietWord:
"#quiet"
LoudWord:
"#loud"
ScriptDepth:
0x00
; The drive the person is standing on, kept across a block being fetched. Not part of the
; saved state below: it is only ever live for the length of one read.
ScriptDrive:
0x00
; Where the arguments are, for the length of one call to scriptOpen. A pointer rather than the
; text, because the text is still sitting in the line that named the script and copying it
; twice would buy nothing.
ScriptArgsFrom:
0x00 0x00
; ---- Two hundred and five bytes, and they are next to each other on purpose ----
;
; Name, blocks left, next block, where in the block, what it was given, and whether it is
; echoing: the whole of a running script. Saving it is one copy because of this order, and
; nothing else may be put between them.
;
; ---- THREE NUMBERS DESCRIBE THIS BLOCK AND ALL THREE MUST AGREE ----
;
; scriptCopyState how many bytes one of them is
; scriptSlotAt how far apart two saved ones are
; ScriptSaved four of them
;
; They did not agree, and that is what this warning is for. Six bytes of line position were
; added in the middle of the block and the count stayed at 71, so the last six bytes of every
; saved script were never saved: a nested script's #quiet leaked back out to the script that
; called it, which is the opposite of what this file's own comment promises. The line position
; that went unsaved with it turned out not to matter - a loop keeps its own copy in the block
; record - but nothing said so, and the next field added would have been chosen at random.
ScriptName:
#Reserve 0d64
ScriptBlocks:
0x00 0x00
ScriptIndex:
0x00 0x00
ScriptAt:
0x00 0x00
; ---- And where the line being read began ----
;
; A loop goes back to the line that opened it, and by the time that line has been read the
; reader is past it. So the position is kept before every line rather than worked out
; afterwards, which cannot be done: a line is not a fixed size and there is nothing to
; subtract.
ScriptLineIndex:
0x00 0x00
ScriptLineAt:
0x00 0x00
ScriptLineBlocks:
0x00 0x00
; Saved with the rest, so that a quiet script calling a loud one gets its quiet back when
; the loud one finishes. A new script INHERITS it rather than resetting, because a build
; that asked for quiet meant its helpers too; only the first script started from the prompt
; begins loud.
ScriptQuiet:
0x00
; ---- What the script was given ----
;
; The rest of the line that started it, kept whole rather than picked apart, because picking
; it apart costs a place to put every piece and a limit on how many there may be. "$1" walks
; this and copies out the word it wants; "$args" is this.
;
; A WHOLE LINE OF ROOM, so that nothing a person can type can be cut short. The line is 127
; and the script's own name has already come off the front of it, so this can never be filled
; - which is worth 128 bytes a level to never have to explain.
ScriptArgs:
#Reserve 0d128
; Three would do - a save happens on the second script and not the first - but four costs
; one more and removes an off-by-one from the only place it could hide.
ScriptSaved:
#Reserve 0d820
ScriptInto:
0x00 0x00
ScriptRoom:
0x00
ScriptLength:
0x00
ScriptChar:
0x00
ScriptBlock:
#Reserve 0d257
+191
View File
@@ -41,13 +41,204 @@
; going out it is A and B together, and neither direction needs a record in memory that ; going out it is A and B together, and neither direction needs a record in memory that
; both sides have to agree on the shape of. ; both sides have to agree on the shape of.
osFileRead 0d20 ; DP0 names it, DP1 says where. Q is zero if it read, DP3 is how many bytes. osFileRead 0d20 ; DP0 names it, DP1 says where. Q is zero if it read, DP3 is how many bytes.
; ---- AND IT WRITES WHOLE BLOCKS ----
;
; A disk is read a block at a time, so a sixteen byte file still puts
; 256 bytes where it is told to. The room given has to be the file's
; length ROUNDED UP to the next 256, and a caller that gives exactly
; the length writes over whatever follows it. DP3 still says how many
; bytes are the file's; the rest is whatever was on the block.
osFileSave 0d21 ; DP0 names it, DP1 is the bytes, A and B are how many. Q is zero if it saved. osFileSave 0d21 ; DP0 names it, DP1 is the bytes, A and B are how many. Q is zero if it saved.
osFileDelete 0d22 ; DP0 names it. Q is zero if it went. osFileDelete 0d22 ; DP0 names it. Q is zero if it went.
osFileRename 0d23 ; DP0 is the name it has, DP1 the name it should have. Q is zero if it moved. osFileRename 0d23 ; DP0 is the name it has, DP1 the name it should have. Q is zero if it moved.
; ---- Reading a file that will not fit ----
;
; osFileRead answers with a whole file in Data Memory, which settles it for anything under
; 64K and settles nothing above. These two are the other way of asking: how big is it, and
; then give me one block of it at a time. Nothing is kept between the calls but the number
; of the block wanted, so there is no handle to open, none to close, and nothing left
; behind by a program that stops in the middle. The system remembers where the last file it
; was asked about lives, so asking for four hundred blocks of one file costs one search of
; the directory rather than four hundred; that is a speed, not a promise, and a caller
; never has to know about it.
;
; THESE TWO SAY WHY WHEN THE ANSWER IS NO, which the others do not. Everywhere else the
; only useful thing to do about a failure is to give up, so one value is enough. These
; exist to be asked questions with - is it there, is there any more of it - and the
; difference between "no disk", "no such file" and "that was the last block" is the answer
; rather than an excuse.
;
; 1 there is no disk
; 2 there is no file of that name
; 3 that block is past the end of the file (osFileBlock only)
; 4 the disk would not read it (osFileBlock only)
osFileInfo 0d26 ; DP0 names it. Q is zero if it is there, DP3 is how many blocks.
osFileBlock 0d27 ; DP0 names it, DP1 says where, A and B are which block from zero.
; ---- Moving about ----
;
; DP0 names a directory. Q is zero if the machine is now in it.
;
; WHAT A PROGRAM CHANGES HERE, THE SHELL PUTS BACK when the program stops - the same
; discipline the Stack and the vector table are held to, and for the same reason. A program
; is entitled to move about; the shell is entitled to find itself where it left off.
;
; This is what makes a bare name mean something to a program: everything a program opens is
; relative to here, so a program given a directory to work in can say "notes.txt" and mean
; the one in it.
osChangeDir 0d28
; ---- Writing a file a block at a time ----
;
; The mirror of osFileInfo and osFileBlock, and the way to write something too big to hold
; in memory. osFileSave stays for a whole document handed over at once, which is what a
; text editor has and what most programs want.
;
; ONE WRITE IS OPEN AT A TIME AND THE SYSTEM HOLDS IT. Reading needs no state - a name and
; an index are the whole question - but writing safely does, because the new file has to
; exist before the old one is thrown away and something must remember which temporary
; belongs to which name. Keeping that here means the careful order is written once instead
; of in every program that streams.
;
; Nothing that already exists is touched until osFileDone, so a disk without room says so
; while the old file is still there.
;
; osFileStart is told the size the way an entry holds one, blocks and a tail, rather than a
; count of bytes - so it reaches the whole disk. osFileSave is handed a byte count in two
; registers and cannot write more than 65,535.
osFileStart 0d29 ; DP0 names it, DP3 is whole blocks, A is bytes in the tail.
osFileWrite 0d30 ; DP1 is the block, A and B together are which one, from zero.
osFileDone 0d31 ; DP3 is whole blocks and A the tail: how big it turned out to be.
osFileFetch 0d32 ; DP1 is where it goes, A and B are which block. Reads one back.
; osFileFetch is what lets a program keep only ONE block of a file in hand while writing
; it. Anything producing two parts of a file at once - an assembler, whose source says
; #Program and #Data in whatever order it likes - has to be able to put a block down, go
; and write somewhere else, and pick it up again where it left off.
; Q is zero if it read, DP3 is how many of its bytes are the file's:
; a whole 0d256 except in a last block that is short. That count is
; why DP3 answers and not a register - 0d256 does not fit in a byte,
; and a count that lied about a full block would make every reader
; treat the end of a file as a special case.
; ---- And with the console ---- ; ---- And with the console ----
; ;
; printString is already up there. This is the other half of what a program prints: a ; printString is already up there. This is the other half of what a program prints: a
; number, in decimal, without leading zeroes. A and B together, so one service covers both ; number, in decimal, without leading zeroes. A and B together, so one service covers both
; a line number and a byte count and there is no need for two. ; a line number and a byte count and there is no need for two.
osPrintNumber 0d24 osPrintNumber 0d24
; ---- Stopping to look ----
;
; A breakpoint. Put SWI osBreak anywhere in a program and the system shows every register as
; the program had them, waits for a key, and carries on.
;
; NOTHING IS OVERWRITTEN, which is what makes this simple. A breakpoint that replaced an
; instruction would have to put it back to continue, and putting it back disarms the
; breakpoint - so firing twice would need the instruction to be stepped over and the
; breakpoint replaced behind it, and this machine has no way to step one instruction. An SWI
; costs two bytes of the program and fires for ever, because there was never anything to
; restore. The price is that it is part of the program: a build with breakpoints in it has
; different addresses from one without.
osBreak 0d25
; ---- How the last start went ----
;
; The loader marks the disk before it hands over and the system clears the mark on reaching
; its prompt, so a mark still set is a start that never arrived. See the boot state in
; sbfs.h for what the numbers mean.
;
; osBootState answers in Q: 0 settled, 1 trying, 2 fell back. A machine with no disk answers
; settled, because there is nothing there to be unsettled about.
;
; osBootSettle puts it back to settled, which is how a machine that fell back is told the
; situation has changed. Q is zero if the disk took it.
;
; THE ONLY WRITE A PROGRAM GETS IS SETTLING. Marking a start as trying or fallen back is
; the loader's business, and a service that let a program claim either would let it lie
; about something the loader has no way to check.
osBootState 0d33
osBootSettle 0d34
; ---- What a program made of it ----
;
; osExit takes a status in A: zero if the program did what it was asked, and a number of
; its own choosing if it did not. IN A RATHER THAN Q, which is not a departure from the
; rule that a service answers in Q - this one takes an argument, the way osPrintNumber
; does, and never returns to answer anything. A is free precisely because a return would
; have put it back, and Q is the ALU's output, so a small number costs four instructions
; there and one in A.
;
; osLastStatus answers in Q with what the last program exited with. The shell does not
; print it: a program that failed has already said so in words, and a number beside that
; would be noise. This is for the thing that cannot read words.
osLastStatus 0d35
; ---- Taking the screen, and giving it back ----
;
; Says that this program is about to use the whole screen and would like what is on it now put
; back when it exits. Q is zero if that was arranged; anything else means it was not, which is
; the ordinary answer on a machine with no volatile drive to keep it on - and a program told
; no should carry on regardless, because it was going to before this existed.
;
; NOT AUTOMATIC, and that is the point: dir and Say print and stop, and their output is the
; reason you ran them. Only a program that says it took the screen gets the screen put back.
osTakeScreen 0d36
; ---- Where the program itself came from ----
;
; DP0 says where to put it and B is how much room there is counting the zero, the same
; bargain osArgument offers. What comes back is the whole path the program was loaded from,
; made absolute: a program found where somebody was standing is named by the bare word that
; was typed, and a bare name means the working directory - which a program is entitled to
; move out of.
;
; ---- Why this and not "open a file beside me" ----
;
; A service that opened a file relative to the program would need a TWIN FOR EVERY FILE
; OPERATION there is: read, save, info, block, start, write, done, delete, rename. One
; service handing back a path composes with all of them, and joining a name to a place is
; then a library rather than a service - Libraries/path.asm, beside print.asm and math.asm.
;
; ---- And why not just move the program there ----
;
; Because a program's ASSETS are relative to the program and its ARGUMENTS are relative to
; the person, and the working directory can only be one of them. Setting it to the program's
; own would mean "Play mytune.tune", typed by somebody in their own directory, looked in
; Play's. The working directory stays the person's; this is how a program finds its own.
osWhereAmI 0d37
; ---- Seeing what is on the disk ----
;
; Everything above takes a name a program already knows. NOTHING HERE COULD FIND OUT WHAT
; NAMES THERE ARE - a machine whose programs can read, write, rename and delete files and
; cannot ask what files exist. dir could only list because it lives in the shell and calls
; the filesystem directly; no loaded program could list anything at all.
;
; DP0 says where to put the name and B how much room there is, counting the zero, the same
; bargain osArgument and osWhereAmI offer.
;
; Q ANSWERS THE KIND RATHER THAN A YES OR NO, which is one value carrying both "is there
; one" and "what is it":
;
; 0 a file
; 1 a directory
; 2 a save that stopped before it committed
; 0xFF there are no more
;
; ---- And the size is not in it ----
;
; A walk hands back a NAME, and a program that wants the size of what it found asks
; osFileInfo about that name. The alternative is a record in memory whose shape both sides
; have to agree on, which is exactly what this file went out of its way to avoid for file
; sizes - and most callers of this want names and nothing else.
;
; ---- One walk at a time, and the system holds it ----
;
; Where a walk has got to is the system's, the way an open write is. A program that starts a
; second walk before finishing the first gets the second; there is one position, not a handle
; per caller. That is the same bargain osFileStart makes and for the same reason: the state
; is small, and a program that stops in the middle leaves nothing behind to clean up.
osDirFirst 0d38
osDirNext 0d39
+1 -3
View File
@@ -94,8 +94,7 @@ textHexWord:
textHexLoop: textHexLoop:
LDA.0 LDA.0
CALL textHexDigit CALL textHexDigit
PSHQ MVQA
POPA
INIB 0xFF INIB 0xFF
CCF CCF
SUB SUB
@@ -273,7 +272,6 @@ textNumberLoop:
POPB POPB
CCF CCF
ADD ; And the digit. ADD ; And the digit.
SETD.1 TextValue
STQ.1 STQ.1
SETD.1 TextDigits SETD.1 TextDigits
@@ -5,7 +5,7 @@
#Program #Program
start: start:
; Load our initial values into A and B. ; Load our initial values into A and B.
INIA 0x00 RSTA
CALL printByteDecimal CALL printByteDecimal
CALL blankSpace CALL blankSpace
; Move the value into B. ; Move the value into B.
@@ -22,8 +22,7 @@ start:
PSHA PSHA
POPB POPB
; Copy Q into A ; Copy Q into A
PSHQ MVQA
POPA
; Print A. ; Print A.
CALL printByteDecimal CALL printByteDecimal
CALL blankSpace CALL blankSpace
+144
View File
@@ -0,0 +1,144 @@
; colours.asm
; Every colour the machine wakes up with, and how to change one.
; Written by Anachronaut
;
; ---- What a colour is on this machine ----
;
; The screen draws CELLS, and a cell is two bytes: which tile, and an attribute. A tile is
; eight by eight pixels and every pixel is a byte - a number, not a colour. What colour that
; number means is looked up in the PALETTE, which is 256 entries of four bytes: red, green,
; blue, and one spare. Four rather than three so that entry n begins at n times four, which
; is a shift; three would need a multiply and this machine has none.
;
; A cell's attribute nibble is ADDED to every number in its tile, sixteen at a time. So the
; same tile drawn with attribute 0 reads palette entries 0 and 1, with attribute 1 it reads
; 16 and 17, and so on. Sixteen banks of sixteen.
;
; The console's glyphs are drawn in numbers 0 and 1 - paper and ink - so for text those
; sixteen banks are sixteen INK AND PAPER PAIRS. Writing the attribute register at port 0x06
; says which one to use.
;
; The palette a machine wakes up with is laid out so one bit inverts a pair:
;
; banks 0 to 7 a colour on black
; banks 8 to 15 the same colour AS the background, with black text on it
;
; So attribute XOR 8 highlights, which is all a cursor is.
;
; ---- Reaching the palette ----
;
; Video memory belongs to the screen, not to the program, so the CPU cannot write it with a
; store. It is reached the way every device's memory is reached: registered as a bank, and
; written through the memory controller. That is what the last part of this program does.
#Program
start:
; The console wants sixteen columns for the name and a bit more, so the wide screen is
; not needed. This is the mode the machine wakes up in and is here to be seen.
RSTA
OUTA 0x31
; ---- Sixteen pairs, one line each ----
;
; Counting in memory rather than in a register, because the loop below uses A and B for
; the arithmetic and there is nowhere else to keep it.
SETD.0 Bank
STA.0 ; Still the zero from the mode write above: SETD does not touch A
nextBank:
LDA.0
OUTA 0x06 ; Draw in this pair from now on
SETD.1 SampleText
RCAL say
; The same bank with bit 3 set, which is the same colour inside out.
LDA.0
INIB 0x08
XOR
MVQA
OUTA 0x06
SETD.1 HighlightText
RCAL say
RSTA
OUTA 0x06 ; Back to plain for the newline
INIA 0x0A
OUTA 0x00
LDA.0
INCA
STA.0
INIB 0d8 ; Eight banks; the other eight are their reverses
CCF
SUB
BNQ nextBank
; ---- And one written by hand ----
;
; Bank 2 is green when the machine starts. This makes its ink orange instead, by writing
; three bytes into the palette - which means reaching video memory, which means the
; controller.
; Give the screen's memory a bank number. The screen brings TWO banks and this only wants
; one of them: port 0x30 owns the atlas, where the tiles and the palette are, and the
; palette is all this touches. Bank 3 is the first number software is allowed to hand out:
; 0, 1 and 2 belong to the machine.
INIA 0d3
OUTA 0xE3 ; DestBank: the number being given
INIA 0x30
OUTA 0xE2 ; SourceLow: the port that owns the memory
INIA 0x03
OUTA 0xE8 ; Command: RegisterBank
; The palette sits at the top of the atlas, at 0xFC00, and entry n is at n times
; four. Bank 2's ink is entry 2 * 16 + 1, which is 33, and 33 * 4 is 132 - so 0xFC84.
INIA 0xFC
OUTA 0xE4 ; DestHigh
INIA 0x84
OUTA 0xE5 ; DestLow
; Writing the controller's Data port puts a byte at the destination and steps it on, so
; three writes are red, green and blue in order.
INIA 0xF0
OUTA 0xE9 ; red
INIA 0x80
OUTA 0xE9 ; green
INIA 0x20
OUTA 0xE9 ; blue
INIA 0x02
OUTA 0x06 ; That pair again, now that it has been changed
SETD.1 ChangedText
RCAL say
RSTA
OUTA 0x06
HALT
; DP1 names a string. Printing is one byte at a time out of port 0x00, which is the oldest
; thing on this machine and has never changed.
say:
LDA.1
BRA sayDone
OUTA 0x00
INCD.1
BRI say
sayDone:
RRET
#Data
Bank:
0x00
SampleText:
" ordinary "
HighlightText:
" highlighted "
ChangedText:
"
bank 2's ink is orange now, because this program said so
"
#Vectors
Boot start
+97
View File
@@ -0,0 +1,97 @@
; frames.asm
; Waiting for the screen, which is the only regular beat this machine has.
; Written by Anachronaut
;
; ---- There is no clock ----
;
; Nothing on a SplitBit can tell you how long a second is. Every program that wanted to
; happen at a certain speed has counted instructions and hoped - which is why Snake's pause
; quietly halved the day a cycle stopped being an instruction and became a memory access.
; The program was right; the thing it was counting had changed underneath it.
;
; A screen finishes drawing sixty times a second, and that is a real beat. It is counted in
; the machine's own cycles rather than the host's, so this program sees sixty frames a second
; whether the emulator is running at its proper rate or as fast as it possibly can.
;
; ---- Waiting rather than spinning ----
;
; WAIT stops the machine until something interrupts it. That is not the same as looping until
; a flag goes up, even though both take the same time and print the same thing: a machine in
; WAIT is not using memory, so its cycles are counted as idle rather than as bus. Run this
; and the last line says so - nearly every cycle it spent, it spent asleep.
;
; Which is the whole argument for having a frame to wait for. On real hardware that is a
; machine that could be doing something else, or nothing at all and drawing less current.
#Program
start:
SETD.0 Frames
RSTA
STA.0
; Ask the screen to interrupt at each frame, then let interrupts in. The screen does not do
; this unless it is asked: an interrupt with nothing installed to catch it is a fault, so a
; machine that started interrupting on its own would take down every program that had never
; heard of frames.
INIA 0x01
OUTA 0x35
SIF
everyFrame:
; A dot a frame, so there is something to watch.
INIA 0d46
OUTA 0x00
; And nothing at all until the next one.
WAIT
SETD.0 Frames
LDA.0
INIB 0d60 ; One second of them
CCF
SUB
BNQ everyFrame
; Put the screen back the way it was found, and stop asking to be interrupted before
; taking away the thing that would catch it.
CIF
RSTA
OUTA 0x35
SETD.0 Done
RCAL say
HALT
; ---- Called sixty times a second ----
;
; A handler runs between two instructions of whatever was going on, so it saves everything it
; touches - which for an interrupt the machine does itself. RETI puts it all back.
frame:
SETD.0 Frames
LDA.0
INCA
STA.0
RETI
say:
LDA.0
BRA sayDone
OUTA 0x00
INCD.0
BRI say
sayDone:
RRET
#Data
Frames:
0x00
Done:
"
that was a second
"
#Vectors
Boot start
Device 0x30 frame
@@ -14,8 +14,8 @@
start: start:
CALL seedGlider CALL seedGlider
SETD ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
generationLoop: generationLoop:
CALL renderBoard CALL renderBoard
@@ -48,8 +48,9 @@ seedGlider:
RET RET
renderBoard: renderBoard:
SETD CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD Board SETD Board
DPUP 0d38 DPUP 0d38
INIA 0d16 INIA 0d16
@@ -193,50 +194,43 @@ countNeighbors:
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d02 DPUP 0d02
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d02 DPUP 0d02
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d32 DPUP 0d32
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d04 DPUP 0d04
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d32 DPUP 0d32
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d02 DPUP 0d02
LDB LDB
CCF CCF
ADD ADD
PSHQ MVQA
POPA
DPUP 0d02 DPUP 0d02
LDB LDB
@@ -313,12 +307,6 @@ RowCount:
ColCount: ColCount:
0x00 0x00
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
; The emulator zero-fills the remainder of Data Memory. Board names the first ; The emulator zero-fills the remainder of Data Memory. Board names the first
; byte of a 648-byte logical allocation (18 * 18 * 2). ; byte of a 648-byte logical allocation (18 * 18 * 2).
@@ -10,8 +10,8 @@
start: start:
CALL seedGlider CALL seedGlider
SETD.0 ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
generationLoop: generationLoop:
CALL renderBoard CALL renderBoard
@@ -38,8 +38,9 @@ seedGlider:
RET RET
renderBoard: renderBoard:
SETD.0 CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD.1 RowCount SETD.1 RowCount
SETD.2 ColCount SETD.2 ColCount
INIA 0d16 INIA 0d16
@@ -250,12 +251,6 @@ RowCount:
ColCount: ColCount:
0x00 0x00
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The ; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The
; original leaves this implicit and leans on Data Memory being zero, which works but ; original leaves this implicit and leans on Data Memory being zero, which works but
@@ -20,7 +20,7 @@ start:
INCD ; Increment to the next spot in the buffer. INCD ; Increment to the next spot in the buffer.
BRI inputLoop ; Loop again to grab more string. BRI inputLoop ; Loop again to grab more string.
inputEnd: inputEnd:
INIA 0x00 ; Set A to 0. RSTA ; Set A to 0.
STA ; Store it in the buffer. STA ; Store it in the buffer.
SETD Buffer ; Set the Data Pointer to the start of the buffer again. SETD Buffer ; Set the Data Pointer to the start of the buffer again.
CALL printString ; Print it out. CALL printString ; Print it out.
+121
View File
@@ -0,0 +1,121 @@
; picture.asm
; The other kind of screen: a byte a pixel.
; Written by Anachronaut
;
; ---- Two ways to have a screen ----
;
; A tile mode costs the machine the number of CELLS that changed. Forty by twenty-five is
; two thousand bytes for a whole screen and four bytes for two cells, which is why text on
; this machine is affordable at all.
;
; A bitmap costs it the number of PIXELS. Three hundred and twenty by two hundred is 64,000
; bytes - four frames of work at a megahertz to replace all of it. So this is not the mode to
; animate a whole screen in; it is the mode to draw a picture in and then leave alone, or to
; change a corner of.
;
; It lives over the top of the map, in the SCREEN bank, because that is the bank whose
; contents change: a picture and a map are the same memory called two different things in two
; different modes. Going to bitmap mode does not clear the text screen, it stops calling it
; one - and coming back finds the map holding whatever the picture put there.
;
; WHAT IT DOES NOT COST IS THE FONT. The tiles and the palette are in the other bank and a
; picture cannot reach them, which is what lets this program draw and then say something.
#Program
start:
; Video memory is the screen's, not this program's, so it is reached the way every device's
; memory is: given a bank number, then written through the memory controller. Banks 0, 1
; and 2 belong to the machine, so 3 is the first one software may hand out.
; TWO OF THEM, because the screen brings two banks and this program writes to both: the
; palette is in the atlas and the picture is in the screen.
INIA 0d3
OUTA 0xE3 ; DestBank: the number being given
INIA 0x30
OUTA 0xE2 ; SourceLow: the port that owns the memory
INIA 0x03
OUTA 0xE8 ; Command: RegisterBank. Three is the atlas.
INIA 0d4
OUTA 0xE3
INIA 0x3A
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; And four is the screen.
; ---- Two hundred and fifty six colours ----
;
; Entry n at 0xFC00 plus n times four. Writing the controller's Data port puts a byte at
; the destination and steps it on, so the whole palette is one address and a loop.
; A already holds 3, because RegisterBank is command 3 and the bank wanted is bank 3.
; Leaving the line out would turn a program that says which bank it means into one that
; works by coincidence, and the coincidence breaks the day either number changes.
INIA 0d3 ; splitlint[redundant-assignment]: said rather than coincided
OUTA 0xE3 ; The ATLAS, which registering the second bank moved off
INIA 0xFC
OUTA 0xE4
RSTA
OUTA 0xE5
SETD.0 Count
STA.0 ; Still the zero from DestLow above: SETD does not touch A
palette:
LDA.0
OUTA 0xE9 ; red climbs
LDA.0
OUTA 0xE9 ; green with it
LDA.0
INIB 0xFF
XOR
MVQA
OUTA 0xE9 ; and blue falls away, so it runs blue to white to yellow
RSTA
OUTA 0xE9 ; the fourth byte is spare
LDA.0
INCA
STA.0
BNA palette ; A comes back to zero after 256 of them
; ---- The picture ----
;
; Two hundred rows of three hundred and twenty pixels, each row one colour. FILL LEAVES THE
; DESTINATION PAST WHAT IT TOUCHED, so the address is set once here and never worked out
; again - which matters, because working out where row n begins would be n times 320 and
; this machine has no multiply.
INIA 0d4
OUTA 0xE3 ; Into the SCREEN bank now, which is where a picture is
RSTA
OUTA 0xE4
OUTA 0xE5 ; Dest 0x0000, the top left corner
INIA 0x01
OUTA 0xE6
INIA 0x40
OUTA 0xE7 ; 320 bytes, which is one row
SETD.0 Count
RSTA
STA.0
rows:
LDA.0
OUTA 0xE2 ; Fill takes its byte from SourceLow: the row number is the colour
INIA 0x02
OUTA 0xE8 ; Command: Fill
LDA.0
INCA
STA.0
INIB 0d200
CCF
SUB
BNQ rows
; And now show it. Nothing above cared which mode the screen was in - the bytes were
; already there, waiting to be called a picture.
INIA 0x02
OUTA 0x31
HALT
#Data
Count:
0x00
#Vectors
Boot start
@@ -1,7 +1,7 @@
; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA. ; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA.
; ;
; This deliberately implements the same algorithm and emits the same text as ; This deliberately implements the same algorithm and emits the same text as
; 16bitSegmentedSieve.asm, making the two versions useful as a direct comparison. ; 16bitSieve.asm, making the two versions useful as a direct comparison.
; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3 ; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3
; marks multiples. CALL preserves the first three pointers automatically. ; marks multiples. CALL preserves the first three pointers automatically.
@@ -18,8 +18,7 @@ repl:
CALL printString CALL printString
CALL readNonSpace CALL readNonSpace
PSHQ MVQA
POPA
; Q, q, or end-of-file exits. ; Q, q, or end-of-file exits.
INIB 0xFF INIB 0xFF
@@ -39,14 +38,12 @@ repl:
BRQ inputError BRQ inputError
CALL readNonSpace CALL readNonSpace
PSHQ MVQA
POPA
SETD Operator SETD Operator
STA STA
CALL readNonSpace CALL readNonSpace
PSHQ MVQA
POPA
CALL readHexByteFirst CALL readHexByteFirst
SETD RightOperand SETD RightOperand
STQ STQ
@@ -98,7 +95,7 @@ readNonSpaceLoop:
INIB 0x0D INIB 0x0D
XOR XOR
BRQ readNonSpaceLoop BRQ readNonSpaceLoop
INIB 0x00 RSTB
CCF CCF
ADD ADD
RET RET
@@ -108,12 +105,11 @@ readNonSpaceLoop:
readHexByteFirst: readHexByteFirst:
CALL clearParseStatus CALL clearParseStatus
CALL hexNibble CALL hexNibble
PSHQ MVQA
POPA
INIB 0xFF INIB 0xFF
XOR XOR
BRQ invalidByte BRQ invalidByte
INIB 0x00 RSTB
SHL SHL
SHL SHL
SHL SHL
@@ -121,11 +117,9 @@ readHexByteFirst:
PSHA PSHA
CALL readNonSpace CALL readNonSpace
PSHQ MVQA
POPA
CALL hexNibble CALL hexNibble
PSHQ MVQA
POPA
INIB 0xFF INIB 0xFF
XOR XOR
BRQ invalidLowNibble BRQ invalidLowNibble
@@ -142,7 +136,7 @@ invalidByte:
STA STA
POPD POPD
INIA 0xFF INIA 0xFF
INIB 0x00 RSTB
CCF CCF
ADD ADD
RET RET
@@ -157,8 +151,7 @@ hexNibble:
CCF CCF
SUB SUB
BRC tryUpperHex BRC tryUpperHex
PSHQ MVQA
POPA
INIB 0d10 INIB 0d10
CCF CCF
SUB SUB
@@ -171,8 +164,7 @@ tryUpperHex:
CCF CCF
SUB SUB
BRC tryLowerHex BRC tryLowerHex
PSHQ MVQA
POPA
INIB 0d06 INIB 0d06
CCF CCF
SUB SUB
@@ -185,8 +177,7 @@ tryLowerHex:
CCF CCF
SUB SUB
BRC badNibble BRC badNibble
PSHQ MVQA
POPA
INIB 0d06 INIB 0d06
CCF CCF
SUB SUB
@@ -195,14 +186,14 @@ tryLowerHex:
badNibble: badNibble:
POPA POPA
INIA 0xFF INIA 0xFF
INIB 0x00 RSTB
CCF CCF
ADD ADD
RET RET
decimalNibble: decimalNibble:
POPB POPB
INIB 0x00 RSTB
CCF CCF
ADD ADD
RET RET
@@ -223,8 +214,7 @@ lowerNibble:
; Return Q=0 if Q was 0xFF, otherwise return a nonzero value. ; Return Q=0 if Q was 0xFF, otherwise return a nonzero value.
resultIsInvalid: resultIsInvalid:
PSHQ MVQA
POPA
INIB 0xFF INIB 0xFF
XOR XOR
RET RET
@@ -288,7 +278,7 @@ evaluate:
INIA 0x01 INIA 0x01
STA STA
RSTA RSTA
INIB 0x00 RSTB
CCF CCF
ADD ADD
RET RET
@@ -360,7 +350,7 @@ multiplyLoop:
multiplyDone: multiplyDone:
SETD Product SETD Product
LDA LDA
INIB 0x00 RSTB
CCF CCF
ADD ADD
RET RET
+299
View File
@@ -0,0 +1,299 @@
; tune.asm
; Playing a melody, which needs a sound device and a clock and has neither by halves.
; Written by Anachronaut
;
; ---- Two devices, because one is not enough ----
;
; The sound device knows how to make a note and knows nothing about when. It has no timer and
; does not interrupt, so a program that only had the sound device could play a tune at
; whatever speed the machine happened to run at, which is not a tune.
;
; The timer is the other half. A period in cycles, a repeat bit, and a line when one has gone
; by: a beat this program SETS rather than one it borrows. Every duration below is a count of
; ticks, and the tick is named in the machine's own cycles, so this plays at the same speed
; whether the emulator is running at a megahertz or as fast as it can go.
;
; ---- The tick is a sixteenth note, and that is the whole point ----
;
; 125,000 cycles, which is a sixteenth note at 120 beats a minute.
;
; This program used to count the SCREEN's frames, because until the timer existed the frame
; was the only regular beat on the machine. A frame is 16,667 cycles, so a sixteenth note is
; seven and a half of them and could not be asked for at all. The arpeggio below was written
; as seven frames, the nearest whole one, which is six and a half per cent fast; and the way
; round the rest of it was to pick a tempo whose subdivisions happened to land on whole
; frames, which is making the tune fit the machine rather than the other way round.
;
; SO THE DURATIONS HERE ARE NOT A CONVERSION OF THE OLD ONES. They are what the music wanted
; in the first place, now that it can be written down: an eighth is two ticks, a quarter is
; four, and the arpeggio is one each rather than a fast approximation of one.
;
; The reason the screen should not have been the clock is not that a frame is slow. It is that
; a display refresh and a music routine have no reason to share a rate.
;
; ---- What a patch costs and what a note costs ----
;
; Setting the sound up is twenty-odd writes, done once before a single note is played. After
; that the inner loop is two: the note, and letting go of it. That split is what the selector
; and value registers are for - see Making A Noise in the Programming Manual.
#Include services.asm
#Program
#Base 0x5000
start:
; ---- The instrument ----
;
; Channel 0, selected once. Every parameter write below lands on it.
RSTA
OUTA 0x41
; A saw wave, which has all the harmonics and so is the one to hear a filter on. Writing a
; port leaves A alone, so the nothing that selected the channel also selects parameter 0,
; which is oscillator 0's waveform. SplitLint will point out any attempt to put it there
; twice.
OUTA 0x42
INIA 0d2 ; Saw
OUTA 0x43
INIA 0x01 ; Oscillator 0, gain
OUTA 0x42
INIA 0xFF ; All of it. A channel arrives at full gain already, so this is
OUTA 0x43 ; saying so rather than changing it.
; A second oscillator a little out of tune with the first, which is the oldest trick there
; is for making one voice sound like more than one.
;
; SWITCHING IT ON IS A SEPARATE WRITE from setting its gain, and it is the one that matters:
; the two oscillators are averaged rather than added, so `active` is structural. Setting a
; gain on an oscillator that is off does nothing at all, silently, which is how the first
; draft of this program came to have a detune in it that could not be heard.
INIA 0x15 ; Oscillator 1, on
OUTA 0x42
INIA 0x01
OUTA 0x43
INIA 0x11 ; Oscillator 1, gain
OUTA 0x42
INIA 0xC0
OUTA 0x43
INIA 0x13 ; Oscillator 1, detune
OUTA 0x42
INIA 0d129 ; Centred on 128, and a step is about nine cents, so this is
; nine cents sharp - a shimmer rather than a wrong note.
OUTA 0x43
; Plucked: no attack to speak of, most of a second of decay, and nothing held.
;
; A sustain of nothing does NOT end the note. It goes quiet and keeps sounding, because a
; voice holding at nothing is what a held key is. Dropping the gate is the only thing that
; ends a note, which is why the loop below does it whether the sound has faded or not.
INIA 0x20 ; Amplitude envelope, attack
OUTA 0x42
INIA 0d10
OUTA 0x43
INIA 0x21 ; Decay
OUTA 0x42
INIA 0d120
OUTA 0x43
INIA 0x22 ; Sustain: nothing
OUTA 0x42
RSTA
OUTA 0x43
INIA 0x23 ; Release
OUTA 0x42
INIA 0d40
OUTA 0x43
; A low pass with the modulation envelope opening it, so each note starts bright and closes
; down. This is what the second envelope is for, and it can only be spent this way because
; the level is shaped by the first one and not by whichever happens to be wired to the
; output.
INIA 0x40 ; Filter, on
OUTA 0x42
INIA 0x01
OUTA 0x43
INIA 0x42 ; Cutoff, low to start with
OUTA 0x42
INIA 0d90
OUTA 0x43
INIA 0x43 ; A little resonance, to hear it move
OUTA 0x42
INIA 0d150
OUTA 0x43
INIA 0x44 ; What opens it: the modulation envelope
OUTA 0x42
INIA 0d2
OUTA 0x43
INIA 0x45 ; And how far, upwards from centre
OUTA 0x42
INIA 0d220
OUTA 0x43
INIA 0x31 ; That envelope's decay, which is the sweep's length
OUTA 0x42
INIA 0d70
OUTA 0x43
INIA 0x32 ; and it closes all the way
OUTA 0x42
RSTA
OUTA 0x43
INIA 0xC0 ; The device's volume, with room left over the top
OUTA 0x46
; ---- The beat ----
;
; The period first and the control byte second, because writing the control byte with the
; run bit set is what LOADS the period. Doing it the other way round starts a timer on
; whatever it was holding before.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54 ; 0x01E848 is 125,000.
INIA 0x07
OUTA 0x51 ; Run, repeat, interrupt.
SIF
; ---- The tune ----
;
; Data Pointer 0 walks the table, and nothing in this loop is a CALL, so it stays where it
; was left without being saved anywhere.
SETD.0 Theme
nextNote:
LDA.0 ; The note. Zero is the end of the tune.
BRA finished
OUTA 0x44 ; Writing the note is what starts it.
INCD.0
LDB.0 ; How many ticks it lasts.
INCD.0
holdNote:
WAIT ; Nothing at all until the timer says a tick has gone by.
DECB
BNB holdNote
; Let go. The note is already fading on its own decay, but dropping the gate is what a
; keyboard does and what the release time is waiting for.
RSTA
OUTA 0x45
BRI nextNote
finished:
; Let the last note ring out rather than cutting it off, then give the timer back - stopping
; it before taking away what catches it, because this program is about to stop existing and
; an interrupt with no handler installed is a fault.
INIB 0d6
lastRing:
WAIT
DECB
BNB lastRing
CIF
RSTA
OUTA 0x51
SWI osExit
; The tick has nothing to do: the loop above is the player, and WAIT only needs something to
; have HAPPENED. A handler still has to exist, because an interrupt with nothing installed to
; catch it is a fault.
;
; Nothing reads the status port here, and that is not an oversight. Taking the interrupt is
; what brings the line down. A program that POLLED the timer instead would have to read 0x50,
; because looking at it is the only thing that answers a tick nobody was interrupted by.
tick:
RETI
#Data
#Base 0x3000
; ---- Notes and how long they last ----
;
; Pairs: a MIDI note, then a count of ticks. 60 is middle C and every 12 is an octave. A zero
; note ends it, which is why there are no rests in here - a rest would want a duration with no
; note, and this table has no way to say that. Adding one is a byte of flag or a note number
; nothing plays, and this program did not need it.
Tune:
0d60 0d2 ; C
0d64 0d2 ; E
0d67 0d2 ; G
0d72 0d4 ; C, an octave up, held twice as long
0d71 0d2 ; B
0d67 0d2 ; G
0d64 0d2 ; E
0d60 0d6 ; and home
0x00
Theme:
0x30 0d2 ; C
0x35 0d2 ; F
0x3C 0d2 ; C+
0x40 0d4 ; E+
0x48 0d4 ; C++
0x45 0d4 ; A+
0x47 0d4 ; B+
0x43 0d6 ; G+
; repeat four times.
0x2C 0d1 ; Gs
0x33 0d1 ; Ef
0x3C 0d1 ; C+
;
0x2C 0d1 ; Gs
0x33 0d1 ; Ef
0x3C 0d1 ; C+
;
0x2C 0d1 ; Gs
0x33 0d1 ; Ef
0x3C 0d1 ; C+
;
0x2C 0d1 ; Gs
0x33 0d1 ; Ef
0x3C 0d1 ; C+
; next chord
0x2E 0d1 ; Bf
0x35 0d1 ; F
0x3E 0d1 ; D+
; next chord
0x2E 0d1 ; Bf
0x35 0d1 ; F
0x3E 0d1 ; D+
;
; next chord
0x2E 0d1 ; Bf
0x35 0d1 ; F
0x3E 0d1 ; D+
;
; next chord
0x2E 0d1 ; Bf
0x35 0d1 ; F
0x3E 0d1 ; D+
; Finally on the C major
0x30 0d1 ; C
0x37 0d1 ; G
0x40 0d1 ; E+
;
0x30 0d1 ; C
0x37 0d1 ; G
0x40 0d1 ; E+
;
0x30 0d1 ; C
0x37 0d1 ; G
0x40 0d1 ; E+
;
0x30 0d1 ; C
0x37 0d1 ; G
0x40 0d1 ; E+
;
0x18 0d8 ; C bass
0x00
#Vectors
Boot start
Device 0x50 tick
Binary file not shown.
+4 -8
View File
@@ -25,8 +25,7 @@ int8mult:
DECA ; Subtract one from the multiplier. DECA ; Subtract one from the multiplier.
BRA int8multDone ; If the multiplier becomes zero, we're done. BRA int8multDone ; If the multiplier becomes zero, we're done.
PSHA ; Push the multiplier back to the stack. PSHA ; Push the multiplier back to the stack.
PSHQ ; Push the running total to the stack. MVQA ; Push the running total to the stack. Pop the running total into A.
POPA ; Pop the running total into A.
BRI int8multLoop BRI int8multLoop
return0: return0:
RSTA RSTA
@@ -58,8 +57,7 @@ int8div:
POPA ; Pop the quotient counter from the stack. POPA ; Pop the quotient counter from the stack.
INCA ; Increment it. INCA ; Increment it.
PSHA ; Push it back onto the stack. PSHA ; Push it back onto the stack.
PSHQ ; Push the running total onto the stack. MVQA ; Push the running total onto the stack. Pop the runing total into A.
POPA ; Pop the runing total into A.
BRI int8divLoop ; Branch back to the loop. BRI int8divLoop ; Branch back to the loop.
int8divDone: int8divDone:
CCF ; Clear the Carry Flag. CCF ; Clear the Carry Flag.
@@ -76,13 +74,11 @@ int8mod:
int8modLoop: int8modLoop:
SUB ; Subtract B from A. SUB ; Subtract B from A.
BRC int8modDone BRC int8modDone
PSHQ MVQA
POPA
BRI int8modLoop BRI int8modLoop
int8modDone: int8modDone:
; Add the divisor to Q to get the remainder. ; Add the divisor to Q to get the remainder.
CCF CCF
PSHQ MVQA
POPA
ADD ADD
RET RET
+235
View File
@@ -54,8 +54,243 @@ int16sub:
STQ ; Store the result. STQ ; Store the result.
RET ; Return to the caller. RET ; Return to the caller.
; ---- Multiplying, which this machine cannot do ----
;
; There is no MUL. What there is instead is an identity:
;
; a * b = qs[a + b] - qs[|a - b|] where qs[n] is n squared over four
;
; because (a+b)^2/4 - (a-b)^2/4 is exactly a*b, and the halves that the flooring throws away
; cancel between the two terms. So a multiply is TWO LOOKUPS AND A SUBTRACT.
;
; ---- And the table is built by adding ----
;
; Which is the part that makes this fit a machine with no multiplier at all. A table of
; squares would need squaring to fill, and this one does not:
;
; qs[n] = qs[n-1] + n/2
;
; and n/2 goes 0, 1, 1, 2, 2, 3, 3 - a number that steps up on every even n. So the whole
; table is one running total and a counter, and nothing harder than an add appears anywhere in
; building the thing that does the multiplying.
;
; 511 entries of two bytes, because a and b are bytes and a+b reaches 510. That is 1,022 bytes
; of Data Memory, which is the price: this is a routine that trades a kilobyte for an
; operation the hardware has not got.
; Fills the table. Call once, before the first multiply, and never again.
mulReady:
SETD.0 MulTable
RSTA
STA.0
INCD.0
STA.0 ; qs[0] is nought.
INCD.0
SETD.1 MulTotalLow
STA.1
INCD.1
STA.1 ; And so is the running total.
SETD.1 MulStep
STA.1
SETD.1 MulToggle
STA.1
; Five hundred and ten more entries to write.
INIA 0xFE
SETD.1 MulLeftLow
STA.1
INIA 0x01
SETD.1 MulLeftHigh
STA.1
mulEntry:
; ---- The step goes up on every even index ----
;
; n/2 for n = 1, 2, 3, 4 is 0, 1, 1, 2: it rises at the even ones. A toggle says which this
; is, which is cheaper than halving a sixteen bit counter every time round.
SETD.1 MulToggle
LDA.1
INIB 0x01
XOR
STQ.1
BNQ mulNoStep ; The toggle is one, so this index is odd and the step stands.
SETD.1 MulStep
LDA.1
INCA
STA.1
mulNoStep:
; The running total, plus the step, sixteen bits.
CCF
SETD.1 MulTotalLow
LDA.1
SETD.2 MulStep
LDB.2
ADD
STQ.1
SETD.1 MulTotalHigh
LDA.1
RSTB
ADD ; Nothing but the carry out of the half below.
STQ.1
; And into the table, where DP0 has been walking all along.
SETD.1 MulTotalLow
LDA.1
STA.0
INCD.0
SETD.1 MulTotalHigh
LDA.1
STA.0
INCD.0
; One fewer to do, counted down sixteen bits.
SETD.1 MulLeftLow
LDA.1
BNA mulCountLow
SETD.1 MulLeftHigh
LDA.1
DECA
STA.1
INIA 0xFF
SETD.1 MulLeftLow
STA.1
BRI mulCounted
mulCountLow:
DECA
STA.1
mulCounted:
SETD.1 MulLeftLow
LDA.1
SETD.2 MulLeftHigh
LDB.2
OR
BNQ mulEntry
RET
; ---- One multiply ----
;
; The operands go in MulA and MulB and the sixteen bit product comes back in MulLow and
; MulHigh. IN MEMORY RATHER THAN IN REGISTERS, because B cannot be stored and a product does
; not fit in one byte anyway - two registers in and two out would spend more instructions
; shuffling than the multiply costs.
mul8:
; The sum, which is nine bits: two bytes can reach 510 between them.
SETD.0 MulA
LDA.0
SETD.1 MulB
LDB.1
CCF
ADD
BRC mulSumOver
RSTA
BRI mulSumHigh
mulSumOver:
INIA 0x01
mulSumHigh:
SETD.0 MulSumHigh
STA.0
MVQA ; Q is still the sum's low half; nothing above touched it.
SETD.0 MulSumLow
STA.0
; The difference, without its sign, which is what the identity wants.
SETD.0 MulA
LDA.0
SETD.1 MulB
LDB.1
CCF
SUB
BRC mulOtherWay ; Borrowed, so B is the larger and the sum goes the other way.
MVQA
BRI mulDiffGot
mulOtherWay:
SETD.0 MulB
LDA.0
SETD.1 MulA
LDB.1
CCF
SUB
MVQA
mulDiffGot:
SETD.0 MulDiff
STA.0
; qs[sum]. An entry is two bytes, so the index is doubled - one turn of the shift register,
; where A is the high half and B the low, which is the way DPUW wants them too.
SETD.0 MulSumHigh
LDA.0
SETD.0 MulSumLow
LDB.0
SHL
SETD.0 MulTable
DPUW.0
LDA.0
SETD.1 MulLow
STA.1
INCD.0
LDA.0
SETD.1 MulHigh
STA.1
; Less qs[difference], sixteen bits, the borrow running from one half to the other.
RSTA
SETD.0 MulDiff
LDB.0
SHL
SETD.0 MulTable
DPUW.0
CCF
SETD.1 MulLow
LDA.1
LDB.0
SUB
STQ.1
INCD.0
SETD.1 MulHigh
LDA.1
LDB.0
SUB
STQ.1
RET
#Data #Data
; ---- The multiply's working ----
;
; MulLow and MulHigh are next to each other on purpose: the product is read as a pair.
MulA:
0x00
MulB:
0x00
MulLow:
0x00
MulHigh:
0x00
MulSumLow:
0x00
MulSumHigh:
0x00
MulDiff:
0x00
MulStep:
0x00
MulToggle:
0x00
MulTotalLow:
0x00
MulTotalHigh:
0x00
MulLeftLow:
0x00
MulLeftHigh:
0x00
; 511 entries of two bytes: n from nought to 510, which is as far as a byte plus a byte goes.
MulTable:
#Reserve 0d1022
Int16operands: Int16operands:
0x00 ; Operand A high byte. 0x00 ; Operand A high byte.
0x00 ; Operand A low byte. 0x00 ; Operand A low byte.
+194
View File
@@ -0,0 +1,194 @@
; path.asm
; Putting a name together with the place another thing is in.
;
; DP0 = a path, DP1 = a name, DP2 = where the answer goes, B = how much room, counting
; the zero on the end
; CALL pathBeside Q is zero if the whole of it fitted
;
; Written by Anachronaut
;
; ---- What this is for ----
;
; A program asks the system where it came from - SWI osWhereAmI - and is handed the path it
; was loaded from. What it actually wants is the path of something NEXT TO that: its tune,
; its tiles, its saved state. This is that one step, and it is a library rather than a
; service because it is arithmetic on two strings and touches nothing the system owns.
;
; That split is the whole reason osWhereAmI hands back a path instead of opening files on a
; program's behalf. A service that opened a file relative to the program would need a twin
; for every file operation there is; a path composes with all of them, and the joining
; happens once, here, on a machine that has no string library to do it in each program.
;
; ---- Everything up to the last separator ----
;
; The place a thing is in is everything up to and INCLUDING the last separator in its path,
; so joining is a copy and not a search backwards from the end. A path with no separator in
; it names something in the working directory, and the answer is then the name on its own -
; which means exactly the same thing, in the same place, and needs no special case.
;
; /Packages/app.Lander/Lander + splash.tune = /Packages/app.Lander/splash.tune
; /Lander.sbx + splash.tune = /splash.tune
; Lander.sbx + splash.tune = splash.tune
;
; ---- The room is counted once, in one place ----
;
; Both halves go through pathPut, which counts the room down and always keeps a byte back
; for the zero. So an answer that did not fit is still a STRING, endable and printable, and
; a caller that ignores Q gets a short path rather than a walk off the end of its buffer.
; It gets a wrong answer, which is why Q exists - but not a broken machine.
;
; The answer is built through a stored pointer rather than DP2, because a CALL puts DP0 to
; DP2 back the way it found them and pathPut would otherwise write the same byte every time.
#Program
pathBeside:
SETD.3 PathPut
STD.2.3
SETD.3 PathRoom
STB.3
; ---- How much of the path is the place it is in ----
;
; Counted rather than pointed at, because what is wanted is a NUMBER OF BYTES TO COPY and
; a pointer would have to be turned into one. The count is set afresh at every separator,
; so what it holds at the end is the last one - which is the one that matters.
PSHD.0 ; The front of the path, to come back to.
RSTA
SETD.3 PathCut
STA.3
SETD.3 PathAt
STA.3
pathScan:
LDA.0
BRA pathScanned
INIB 0x2F
CCF
SUB
BNQ pathScanStep
; A separator, so everything up to and including it is the place.
SETD.3 PathAt
LDA.3
INCA
SETD.3 PathCut
STA.3
pathScanStep:
SETD.3 PathAt
LDA.3
INCA
STA.3
INCD.0
BRI pathScan
pathScanned:
POPD.0
SETD.3 PathCut
LDA.3
SETD.3 PathLeft
STA.3
pathPlace:
SETD.3 PathLeft
LDA.3
BRA pathName ; All of the place is written, or there was none of it.
DECA
STA.3
LDA.0
CALL pathPut
BNQ pathNoRoom
INCD.0
BRI pathPlace
pathName:
PSHD.1
POPD.0
pathNameChar:
LDA.0
BRA pathMade
CALL pathPut
BNQ pathNoRoom
INCD.0
BRI pathNameChar
pathMade:
CALL pathEnd
RSTA
RSTB
CCF
ADD ; Q is zero: the whole of it fitted.
RET
pathNoRoom:
CALL pathEnd
INIA 0x01
RSTB
CCF
ADD
RET
; The zero that makes it a string. There is always room for it: pathPut refuses the byte
; that would have taken the last one.
pathEnd:
SETD.3 PathPut
LDD.2.3
RSTA
STA.2
RET
; A holds a character. Puts it where the answer has got to and steps that on. Q is one if
; there is no room for it.
pathPut:
SETD.3 PathHold
STA.3 ; The character, across the pointer being fetched and put back.
SETD.3 PathRoom
LDB.3
DECB
BRB pathPutFull ; Only the zero's worth left, so this byte cannot be written.
STB.3
SETD.3 PathPut
LDD.2.3
SETD.3 PathHold
LDA.3
STA.2
INCD.2
SETD.3 PathPut
STD.2.3
RSTA
RSTB
CCF
ADD
RET
pathPutFull:
INIA 0x01
RSTB
CCF
ADD
RET
#Data
; Where the answer has got to, and how much room is left in it.
PathPut:
0x00 0x00
PathRoom:
0x00
; How much of the path is the place it is in, how far the scan has got, and how much of the
; place is still to be written.
PathCut:
0x00
PathAt:
0x00
PathLeft:
0x00
; One character, across the fetching and putting back of the pointer it is written through.
PathHold:
0x00
+472
View File
@@ -0,0 +1,472 @@
; player.asm
; Four voices on one clock: the part of a music player that is not the music.
; Written by Anachronaut
;
; A program that includes this supplies the tune - four order lists called Order0 to Order3,
; and the sequences they name - and gets the scheduler, the patch loader and the state that
; goes with them. It does NOT supply the beat: setting the timer up and waiting on it belongs
; to the program, because how long a piece rings at the end and what stops it are its business
; and not this file's.
;
; What a sequence is, what a command is and where repetition comes from are all described in
; Apps/Play.asm, which is the first thing to read this.
;
; The caller must define, in its Data Segment:
;
; PatchTable one two-byte address per patch
; SequenceTable one two-byte address per sequence
; VoiceStart four bytes, the patch index each voice starts on
;
; and point each voice's order cursor at a list of one-byte sequence indices ending in 0xFF.
; A tune read from a file needs none of that: useTune does all of it out of the header.
;
; It must call loadStartPatches once, then stepVoice once a tick for each voice, with A
; holding the channel and DP1 the voice's record. Playing counts down as voices run out, and
; reaching nought is the piece being over.
;
; INDICES THROUGH TABLES, RATHER THAN ADDRESSES, because that is the shape a tune read from a
; file has to be: the two tables are the only places an address lives, so loading a tune means
; adding a base to them and nothing else. Keeping the assembled-in form the same shape is what
; makes reading one from a file change no code here at all.
#Program
; ---- One voice, one tick ----
;
; A is the channel and DP1 is the voice's six bytes. Both survive the CALL that got here,
; which is what lets the caller say which voice it means in two instructions.
;
; +0 order cursor +2 sequence cursor +4 count +5 live
stepVoice:
SETD.3 ThisChannel
STA.3 ; A is wanted for other things between here and using it.
DPUP.1 0d5
LDA.1 ; Live?
DPDN.1 0d5
BRA stepDone
DPUP.1 0d4
LDA.1
DECA
STA.1 ; One tick less of whatever is sounding.
DPDN.1 0d4
BNA stepDone ; Still holding it.
; ---- The count ran out, so this is a boundary ----
;
; Select the channel FIRST. Every sound port below writes to whichever channel was last
; named, so a voice that forgot would be playing somebody else's part.
LDA.3
OUTA 0x41
RSTA
OUTA 0x45 ; Let go of the note that just ended.
DPUP.1 0d2
LDD.0.1 ; DP0 is now this voice's place in its sequence.
DPDN.1 0d2
; ---- Read events until one of them takes time ----
;
; A command takes none, and neither does the end of a sequence, so both come back here.
; A sequence of nothing but commands would spin in this loop for ever, which is a hang
; rather than a wrong note - the one malformed sequence worth a compiler refusing.
stepEvent:
LDA.0
INIB 0xFF
XOR ; XOR answers in Q and leaves A holding the event.
BRQ stepSequenceEnd
; The top bit says command, and 0xFF is already dealt with above.
INIB 0x80
AND
BNQ stepCommand
; ---- A note, or a rest, and how long it lasts ----
INCD.0
LDB.0
INCD.0
DPUP.1 0d2
STD.0.1 ; The sequence cursor, moved past this event.
DPUP.1 0d2
STB.1 ; And the count it will be held for.
; A is still the event. Zero is a rest, which is a duration with nothing started.
BRA stepDone
OUTA 0x44 ; Writing the note is what starts it.
stepDone:
RET
; ---- 0x80: play the rest of this voice on that patch ----
;
; The address follows the command. The sequence cursor is put away before the patch is loaded
; and taken out again after, because loadPatch walks DP0 and a CALL hands back the pointer it
; was given rather than the one this needs next.
stepCommand:
INCD.0
LDA.0 ; Which patch: one byte of index, not an address.
INCD.0 ; Past it, on whatever comes next.
DPUP.1 0d2
STD.0.1
DPDN.1 0d2
SETD.2 PatchTable
LDD.2.2 ; The table, wherever this tune put it.
DPUA.2
DPUA.2 ; Twice, because an entry is two bytes.
LDD.2.2 ; And DP2 follows the address it is now holding.
PSHD.2
POPD.0 ; DP0 is the patch.
LDA.3 ; And A the channel, which loadPatch selects.
CALL loadPatch
DPUP.1 0d2
LDD.0.1 ; Back to where the sequence had got to.
DPDN.1 0d2
BRI stepEvent
; ---- The sequence ended, so take the next one from this voice's order list ----
;
; AN ORDER LIST IS INDICES, one byte each, ending in 0xFF - the same byte that ends a sequence.
; SequenceTable is the only place a sequence's address lives.
;
; That is what makes a tune loadable without walking it: nothing inside a sequence or an order
; list is an address, so putting one in memory means adding the load address to two tables and
; nothing else. A loader that had to parse sequences looking for addresses to fix up is a
; loader that a malformed file can walk off a cliff.
;
; It was addresses here, terminated by a zero, on the reasoning that no sequence could live
; below this player's 0x3000 base. True of a loaded program and false of a boot image based at
; zero, so the first test written against it read its own first sequence as the end of the
; list and played nothing at all. An address is not a good place to hide a flag unless the
; address is impossible in every program rather than in this one.
stepSequenceEnd:
LDD.0.1 ; DP0 is the order cursor, which is the first thing in the record.
LDA.0 ; Which sequence comes next.
INIB 0xFF
XOR
BRQ stepOrderEnd
INCD.0
STD.0.1 ; The order cursor, moved past this entry.
SETD.2 SequenceTable
LDD.2.2 ; The table, wherever this tune put it.
DPUA.2
DPUA.2
LDD.2.2 ; And the sequence that index names.
DPUP.1 0d2
STD.2.1 ; And the sequence cursor set to the new one.
DPDN.1 0d2
PSHD.2
POPD.0
BRI stepEvent ; Which has events in it, so read one.
stepOrderEnd:
; Nothing left for this voice. The gate is already down, so its last note is fading.
DPUP.1 0d5
RSTA
STA.1
SETD.3 Playing
LDA.3
DECA
STA.3
RET
; ---- The instrument each voice starts on ----
;
; STARTING STATE IS DECLARED RATHER THAN ASSUMED. A voice whose instrument was never said
; would play on whatever the device woke up with, or worse on whatever the last tune left -
; and that is a fault this machine has met elsewhere, where a program run a second time starts
; with the memory the first run left, because loading is what initialises and running is not.
loadStartPatches:
RSTA
loadStartOne:
SETD.3 ThisChannel
STA.3
SETD.2 VoiceStartAt
LDD.2.2
DPUA.2
LDA.2 ; Which patch this voice starts on.
SETD.2 PatchTable
LDD.2.2
DPUA.2
DPUA.2
LDD.2.2
PSHD.2
POPD.0
LDA.3
CALL loadPatch ; A survives a CALL, so it is still the channel below.
INCA
INIB 0d4
CCF
SUB
BNQ loadStartOne
RET
; ---- base + offset, into wherever the caller wants it ----
;
; DP0 names two bytes of offset and DP1 two bytes to put the address in. They may be the same
; place, which is how a table is relocated in position: the low byte is written before the
; high byte is read, so nothing is clobbered under itself.
;
; The low bytes carry into the high ones, which is the whole reason ADD takes the Carry Flag.
tuneAddr:
SETD.2 TuneBase
CCF
INCD.0
LDA.0 ; The offset, low byte.
INCD.2
LDB.2 ; And the base, low byte.
ADD
INCD.1
STQ.1
DECD.0
LDA.0 ; The offset, high byte.
DECD.2
LDB.2
ADD ; With the carry the low bytes made.
DECD.1
STQ.1
RET
; Every entry of the table at DP0 turned from an offset into an address. B is how many.
tuneReloc:
PSHD.0
POPD.1
CALL tuneAddr ; A CALL hands DP0 and DP1 back, so both are still the entry.
INCD.0
INCD.0
DECB
BNB tuneReloc
RET
; ---- A tune, from wherever it was put ----
;
; DP0 names its first byte. EVERYTHING IN A TUNE IS AN OFFSET FROM THERE, so this adds the
; base to the two tables and the four order lists and nothing else in the file is touched. No
; sequence is walked, and nothing inside one is an address to be found and corrected - which
; is what makes a malformed tune something that plays wrongly rather than something that takes
; the loader with it.
;
; Q is nought if the tune was taken, and anything else if the file was not one.
useTune:
SETD.1 TuneBase
STD.0.1
; "SBTU", and version one. A file that is not a tune has to be refused here, because
; everything below reads offsets out of it and jumps to what they name.
SETD.1 TuneMagic
INIB 0d5
useTuneMagic:
LDA.0
PSHB
LDB.1
XOR
POPB
BNQ useTuneNo
INCD.0
INCD.1
DECB
BNB useTuneMagic
; The tick, straight into the timer. Three bytes, most significant first, which is the
; order the ports take them in.
LDA.0
OUTA 0x52
INCD.0
LDA.0
OUTA 0x53
INCD.0
LDA.0
OUTA 0x54
; How many of each, kept before the pointers move.
INCD.0
LDA.0
SETD.1 TunePatches
STA.1
INCD.0
LDA.0
SETD.1 TuneSequences
STA.1
; The two tables.
INCD.0
SETD.1 PatchTable
CALL tuneAddr
INCD.0
INCD.0
SETD.1 SequenceTable
CALL tuneAddr
; And an order list each.
INCD.0
INCD.0
SETD.1 Voice0
CALL tuneAddr
INCD.0
INCD.0
SETD.1 Voice1
CALL tuneAddr
INCD.0
INCD.0
SETD.1 Voice2
CALL tuneAddr
INCD.0
INCD.0
SETD.1 Voice3
CALL tuneAddr
; The starting instruments are not an offset but a place IN the file, so they are found by
; counting rather than by adding.
SETD.0 TuneBase
LDD.0.0
DPUP.0 0d22
SETD.1 VoiceStartAt
STD.0.1
; Now the tables themselves, whose entries are offsets like everything else.
SETD.0 PatchTable
LDD.0.0
SETD.1 TunePatches
LDB.1
CALL tuneReloc
SETD.0 SequenceTable
LDD.0.0
SETD.1 TuneSequences
LDB.1
CALL tuneReloc
CALL startVoices
; Q is nought, which is how this says it worked. There is no instruction that sets Q: it is
; the ALU's output and nothing else, so saying nought means doing a sum that comes to it.
RSTA
RSTB
XOR
RET
useTuneNo:
; Q is already not nought, because that is what got here.
RET
; ---- Four voices at the beginning of their order lists ----
;
; Whoever supplied the tune has set the order cursors; this sets everything else. A voice
; starts on Empty with a count of one, so its first tick runs the count out, finds the end of
; a sequence, and goes to the order list for the real first one. The beginning of a piece
; needs no special case anywhere.
startVoices:
SETD.1 Voice0
CALL startOneVoice
SETD.1 Voice1
CALL startOneVoice
SETD.1 Voice2
CALL startOneVoice
SETD.1 Voice3
CALL startOneVoice
INIA 0d4
SETD.1 Playing
STA.1
CALL loadStartPatches
RET
startOneVoice:
SETD.0 Empty
DPUP.1 0d2
STD.0.1
INCD.1
INCD.1
INIA 0d1
STA.1 ; A count of one, which runs out on the first tick.
INCD.1
STA.1 ; And live.
RET
; ---- A patch, onto the channel named by A ----
;
; A count, then that many pairs of parameter and value: the format SoundPatch writes and the
; one Lander already plays. B holds what is left, which costs nothing to keep - a CALL saves
; B, so a caller's count is not disturbed by a patch being loaded inside its loop.
;
; The channel is selected FIRST. Every parameter write below lands on whichever channel was
; named last, so a patch loaded without one would be quietly rewriting somebody else's voice.
loadPatch:
OUTA 0x41
LDB.0
INCD.0
loadPatchPair:
LDA.0
OUTA 0x42
INCD.0
LDA.0
OUTA 0x43
INCD.0
DECB
BNB loadPatchPair
RET
#Data
; Six bytes a voice: order cursor, sequence cursor, count, live.
;
; A LABEL WRITTEN HERE COMES OUT AS ITS ADDRESS, two bytes, most significant first - which is
; exactly the shape LDD reads. So a voice starts pointed at its order list and at Empty
; without a line of code: nothing relocates on this machine, so the address the assembler
; wrote is the address it will have.
;
; Empty is a sequence of nothing but its end marker, and the count starts at one. The first
; tick runs the count out, finds the end, and goes to the order list for the real first
; sequence - so the beginning of a piece needs no special case anywhere.
Voice0:
NoOrder Empty 0d1 0d1
Voice1:
NoOrder Empty 0d1 0d1
Voice2:
NoOrder Empty 0d1 0d1
Voice3:
NoOrder Empty 0d1 0d1
Playing:
0d4
ThisChannel:
0x00
; Where this tune's tables are. Pointers rather than the tables themselves, because a tune
; read from a file puts them wherever it was put, and the engine must not care which.
PatchTable:
0x00 0x00
SequenceTable:
0x00 0x00
VoiceStartAt:
0x00 0x00
TuneBase:
0x00 0x00
TunePatches:
0x00
TuneSequences:
0x00
TuneMagic:
0x53 0x42 0x54 0x55 0x01 ; "SBTU" and version one.
; An order list of nothing, which every voice starts pointed at. WHOEVER SUPPLIES THE TUNE
; SETS THE REAL ONE - useTune out of a file's header, or a program with a tune of its own in
; eight lines - so the records below name nothing that a caller has to have defined. They used
; to name Order0 to Order3, which made four order lists part of the contract even for a caller
; whose tune comes from a file and never has any.
NoOrder:
0xFF
; The sequence a voice starts on, so that its first tick goes through the order list like
; every other bar does.
Empty:
0xFF
+2 -2
View File
@@ -97,10 +97,10 @@ printByteDecimal:
; Expects A to contain the byte you want to print as a hexadecimal value. ; Expects A to contain the byte you want to print as a hexadecimal value.
printByteHex: printByteHex:
INIB 0x00 ; Set B to 0. RSTB ; Set B to 0.
SHR SHR SHR SHR ; Shift Right four times to move the high nybble into the lower half of A. SHR SHR SHR SHR ; Shift Right four times to move the high nybble into the lower half of A.
CALL printHexDigit ; Print the nybble. CALL printHexDigit ; Print the nybble.
INIA 0x00 ; Set A to 0. RSTA ; Set A to 0.
SHL SHL SHL SHL ; Shift left four times to move the low nybble back into the lower half of A. SHL SHL SHL SHL ; Shift left four times to move the low nybble back into the lower half of A.
CALL printHexDigit ; Print the nybble. CALL printHexDigit ; Print the nybble.
RET ; Return to the caller. RET ; Return to the caller.

Some files were not shown because too many files have changed in this diff Show More