Commit Graph
176 Commits
Author SHA1 Message Date
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 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 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 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 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 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
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 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 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 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