main
223
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2e9cb9af7e |
Comments are thrown away by the reader, not read one byte at a time
A comment used to walk through tokGet and srcNext and be classified,
character by character, on the way to being discarded. Comments are most
of what this assembler reads: 87 per cent of what Say.asm pulls in once
services.asm is counted, 59 per cent of CosmOS. The house style here is
dense commentary, so the assembler is penalised more than most by its own
sources.
The saving is not the classifying, it is the BOOKKEEPING. srcNext loads
and stores the walking pointer through memory for every character and
asks numCompare whether the buffer is used up. srcSkipComment keeps the
pointer in a data pointer for a whole run and the newline in B, so a
comment byte costs a load, a compare and two steps. A run is capped at
255 so one byte can count it, which is the only reason it loops.
Measured, each version with its own rebuilt images:
without with
hello 791,957 586,184
Say 9,924,401 4,506,702 2.20x
Files 13,197,710 7,188,351 1.84x
Keys 23,091,447 15,069,880 1.53x
cosmos 886,498,996 789,982,899 1.12x
Which tracks the comment ratios: Say gains most and cosmos least, in
proportion to how much of each is prose.
---- And two mistakes worth keeping ----
The scratch went among the READER'S STATE, which is a block copied whole
by a count written down somewhere else - so every saved file lost the
last four bytes of itself and an include came back with its pointer
wrong. The comment above that block says not to do this, in capitals.
That is twice this week: scriptCopyState had the same shape this morning.
And a file that ends inside a comment has to put back the file that
included it, exactly as srcAtEnd does for a character. NOTHING IN THIS
REPOSITORY ENDS THAT WAY - every source here ends on a line of code with
a newline after it - so break.sh could not catch an error in that path
because nothing reached it. tail.asm is generated with no newline on its
last line for that reason, and usestail.asm names a label after the
include, which is what goes missing when the include never returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
|
||
|
|
563bc20a75 |
Tab lists its matches in columns, and says what it can in colour
Tab's listing was six instructions: print the name, print two spaces. It was the crown of this shell for a while and looks plain next to ls. Columns cost NOTHING EXTRA. Tab already walks its candidates twice - once to find the answer, once to show the matches, and tabRunSources exists because those are the same walk asked two questions. The longest match is counted on the first, which already visits every one of them, so the listing needs no walk of its own to know how wide a column should be. Padding goes before a name rather than after, so a row ends on a name. Directories are blue and the shell's own commands are green. Both are free: Tab appends the separator itself, and a built-in is not a file at all - which is also the only way anybody could know it will run. A FILE IS NOT COLOURED, and that is a decision. Whether a file will run is a read of its first block, which is what ls does and what makes ls cost twice what it otherwise would. Here it would be worse than slow: candidates are offered from inside a directory walk, and looking a file up would overwrite the very fields holding the walk's own position. Doing it safely means holding every candidate name in memory, which is a buffer the shell would carry whether anybody pressed Tab or not - and ls is one keystroke away. A Tab press already costs about 200,000 cycles, so the read was not the objection. ---- And the machine could no longer build itself ---- Found by make test, not by reading. The native assembler ran out of room for label names on cosmos.asm: 16,758 bytes against 16,384. The index was 1,341 of 1,536 in the same breath. Which is scratch.asm's own warning happening a second time - "two ceilings a hundred bytes apart look like one ceiling until the first is lifted" - so both were raised, out of the seventeen kilobyte page that file deliberately left unclaimed against exactly this. Names to 26,624 and the index to 2,048, both left about a third clear, with 1,792 bytes still unclaimed for the same reason. A name is thirteen bytes on average and an index entry is four, so the arena will always be the one that speaks first. That is now written down where the two numbers are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
eaeb473176 |
ls says in green what the shell will start
Directories were already blue. Something the shell will run is green now, and it is asked by LOOKING rather than looked up. This was priced once as needing a runnable bit in the directory entry, set by the filesystem from a list of magic numbers it would have to be taught - declined twice, for putting format knowledge in the filesystem and giving two implementations a registry to keep in step. It costs nothing of the sort any more. The shell decides what to run by reading a file's first block, so "will this run" is a question with an answer already, and this asks the same one the shell would: SBEX for a program, "#!" for a script. Nothing is written down and nothing has to agree about anything. The price is a block read per file, which is exactly what the bit existed to avoid: a listing of thirty seven files went from 260,593 cycles to 635,321. It is paid in ls and not in dir on purpose - dir is the listing you audit and is built into the shell, this is the one you read and was loaded off the disk anyway. The fast one stays fast. plain.script is the proof on the test disk: it sits among a dozen scripts that are green and is not one, because it is the fixture with no shebang and the shell will not start it. The extension is decoration and the colour is the truth, which is the whole of what running by content means, finally visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
b83ba5bf7a |
A colour reaches a terminal as well as the screen
The console's attribute has always meant something to the screen and nothing to the serial line: its low nibble picks one of sixteen ink and paper pairs, and only videoPutCell ever read it. So the fault screen's red was red in the window and grey down the wire, and Examples/colours printed " ordinary highlighted " with nothing to tell them apart. It is said in ANSI now, on the same terms the cursor is said in: a register write only marks it and the next character sends it, so setting a scheme and printing nothing says nothing, and setting the same scheme twice costs one sequence rather than two. Only the scheme nibble crosses - the page bits say which tiles a cell draws from, which is a fact about the screen's own art. THE ORDER WAS ALREADY RIGHT, which is worth saying because it looks like a borrowing and is not. Both sets enumerate a three-bit colour, red green blue counted in binary: one is red in both, three is yellow in both, six is cyan in both. The same arithmetic done twice, forty years apart. The one place they differ is slot 0, and that difference is forced - this screen is ink on black, so ink cannot be black, and slot 0 is grey where ANSI's is black. Every sequence begins with a reset, so going from bank 8 to bank 1 does not write red on the grey paper bank 8 left behind. Scheme 0 is a bare reset rather than grey on black, and a terminal is assumed to start plain - so a machine that never asks for a colour says nothing at all, and one that does put the terminal back on its way out. Two ways out, because the two endings have different rules: stopping on purpose goes through stdio, since atexit runs BEFORE the buffer is flushed and a reset written to the file descriptor would arrive in front of the text it is meant to follow. Dying on a signal writes the four bytes directly and accepts that the buffer may be lost. colourTest walks all sixteen and then halts WITH ONE STILL SET, so the recording shows the reset after the halt line rather than before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
4e61158b11 |
The splash stops sounding before it ends, so listen earlier
The check asked whether music was still playing four seconds in, and the score it listens to ends its every voice on a rest - twelve ticks of silence that hold the logo up while the voices decay. So it was measuring exactly the quiet the score asks for and calling a perfect tune a failure. Three seconds instead, which is still twice the second and a half the silent path holds the logo for, and which is what the check is actually about: that this is the tune and not the hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
f351ee8844 |
Every score on the disk, not a named one
The makefile compiled splash.score by name, so intro.score sat beside it turning into nothing - which is exactly the failure the mirror three lines above exists to prevent, and its comment says so: a list in a makefile goes stale the moment somebody adds a file, and what they forgot is invisible until they go looking for it on the machine. Found by the user going looking for it on the machine. Every .score in Programs/Tunes is compiled and put on the disk now, and every patch in Programs/Sounds is converted first so a score can name any of them. The source is mirrored to /Source/Tunes with everything else that was written; the tune goes to the root, where a program looking for one expects it - which is worth writing down, because the two being in different places is the thing that sent somebody hunting. two.score moved to Tests. It is a fixture, it names patches that only exist in the test build, and it was in the music tree only because that is where scores were when it was written - which the game disk build found immediately by failing to compile it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
dfc9d9ef7f |
A score is written and a tune is what the machine reads
The convention, at the user's asking, and it is the one this project already has everywhere else: a .asm is written and a .sbx or a .bin is what the machine loads. A .score and a .tune are the same pair one subject along. It is not only tidiness. I read the user's splash.tune as a compiled tune yesterday, dumped its header, and got a tick of seven and a half million cycles and ninety seven patches out of what was plainly a text file. Different names make that a thing nobody has to notice. AND THE SPLASH WAS SILENT ON THE DISK THAT MATTERS. The play disk mirrors every .asm and puts every app, and nothing on it put a compiled tune - so make run-cosmos and make run-voyager both booted a Lander that read /splash.tune, did not find one, and held the logo in silence. Only the test disk had it, because I had added it there and stopped. The makefile now compiles Programs/Tunes/splash.score with TuneC and puts the result on the disk, and the mirror's prerequisite list learned about .score files so that changing the music rebuilds the disk. That is the same failure the mirror was built for, in a file type the mirror did not know about yet: tune.asm went into Examples once, the image was not remade, and it was simply not there. Measured on the real disk: music from 0.9 s to 9.6 s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
fee2b1ef10 |
One missing patch says one thing
A patch file that could not be read left the name unregistered, so every #Voice naming it failed as well, and then the check that a voice has an instrument failed for each of those. One wrong path produced seven messages and only the first was worth reading. A patch that cannot be read is still a patch that was NAMED. It is registered either way now, with its bytes marked missing, so everything below resolves the name and says nothing. Nothing is written regardless - one problem is enough to stop that - so a patch with no bytes never reaches a file. The damage from the old behaviour was not the extra lines. It is that a compiler which says one thing seven ways teaches people to read the last line, which is the one that matters least. Checked by counting: one missing patch, three voices using it, and the count of messages mentioning it has to be one. break.sh confirms it by putting the old behaviour back on the failure path alone - the first attempt at that break stopped every tune compiling and the disk build failed before any test ran, which is break.sh being right about a break that proved nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
fb672d7761 |
A manual for writing tunes
The fifth document, and the one somebody would want first if they had a piece of music and this machine. What a tune is, how to write one, what the compiler refuses and why, how to build and play one, and the bytes it holds for anything that would rather write one itself. The last of those matters more than it looks: Tests/maketune.py writes tunes without going through TuneC, and the suite checks the two agree byte for byte. A format with two implementations needs a specification they are both held to rather than one of them being the specification. The refusals get a table of their own with the reason beside each, because every one of them is something the PLAYER cannot notice - it has no names, no lengths, and no way to tell "no starting instrument" from "instrument nought" once a tune is loaded. Documented as reasons rather than as rules, so that somebody meeting one knows what it saved them from. docs.sh now settles the manual against the compiler both ways: every directive TuneC takes has a row, and the limits the manual quotes are the compiler's own #defines. Verified with break.sh - a directive removed from the manual and a limit raised in the compiler are both caught. One that was not caught first time and should not have been: removing the #Use row from one table left it documented in the other, which is the check being right and my break being wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
2808688fa1 |
TuneC: a written tune becomes the bytes the player reads
The compiler, and the last thing the ladder was waiting for. A tune names
its instruments, writes sequences of notes and durations, and gives each
voice an order list of sequence names - which is where repetition comes
from, since a phrase played four times is written once and named four
times.
#Tick 0d125000
#Patch Oboe oboe.patch
#Voice 0d0 Oboe
#Sequence Verse
0d64 0d4 0d67 0d4 0d72 0d8
#Order 0d0
Verse Verse Ending
"#" is a directive and ";" is a comment, exactly as in SplitBit assembly
and in the shell's scripts, and numbers are written the way the assembler
writes them. One rule across the machine rather than a third dialect -
and the rule earned itself immediately: the first tune I wrote said
"#Voice 0" and was refused, correctly, for a bare number.
WHAT IT REFUSES IS EVERYTHING THE PLAYER CANNOT NOTICE. The machine has
no names, so it cannot say a sequence does not exist. It has no lengths,
so it cannot say the voices will come apart four bars after the mistake.
A duration of nought is counted down to 255 and held, which sounds like a
hang rather than an error. And by the time a tune is loaded, "no starting
instrument" and "instrument nought" are the same byte - so the user's
ruling, that a voice with a part and no instrument is an error, can only
be kept here.
SoundPatch gains --blob, writing the same table as raw bytes. It stays
the only thing that reads soundThing's JSON: a second program parsing
that format is a second opinion about what a patch means, and the seam
between two opinions is where the LFO bug lived for a fortnight. Patches
are found beside the tune and then on a -I path, the way an include is.
THE TEST IS THAT TWO IMPLEMENTATIONS AGREE. maketune.py lays the fixture
out by hand and TuneC compiles a written source, and the suite checks
they match byte for byte - the discipline SplitDisk and sbfs.asm are held
to, for the same reason: either alone is only self-consistent. The
fixture predates the compiler, so this is also TuneC checked against
something written before it existed. Four more checks cover the four
refusals.
Also: the SoundPatch binary was tracked, alone among the six tools, and
.gitignore lists every other one. Untracked, and TuneC added beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
1b251fb290 |
M1: the tune keeps a beat it sets, instead of one it borrowed
tune.asm counted the screen's frames, because until
|
||
|
|
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 |
||
|
|
8631a78229 |
Backspace, whatever the terminal calls it
CosmOS's line editor looks for 0x08, which is what Voyager's keyboard
sends. A POSIX terminal sends its own erase character instead, and on
most of them that is 0x7F.
It stayed hidden while the terminal was doing the editing: canonical
mode consumes the erase character itself and hands over a finished
line. Key mode turns ICANON off, which is the point of it, so from the
day the shell started editing its own line -
|
||
|
|
019c93a587 |
The console draws a tab instead of dropping it
consoleDraw gave meanings to newline, carriage return and backspace and dropped every other byte below the first glyph. A tab was one of those, so it left no mark on the screen at all - while the same byte went down the serial line, where a host terminal laid it out perfectly. That is why a tab separated file read correctly and displayed wrongly. Type and More were never at fault: they hand the file's bytes to the console unchanged, and the console is where the tabs stopped. An assembler symbol table came out with its fields run together. A tab now moves the cursor to the next stop, eight columns apart, and wraps when the next stop would reach or pass the last column - which is what an ordinary character does at the edge, rather than a rule only tabs obey. It MOVES rather than writing spaces, the way a terminal does: a carriage return followed by a tab steps over what is on the line and leaves it. Kept in the console rather than expanded by Type, More, and every future program that prints text. Three checks in video.sh, each of which fails on a different mistake: a tab renders the same screen as the spaces it stands for, one that runs off the edge renders the same screen as a newline, and sixteen letters tabbed across still have their ink - which is the one that fails if a tab is implemented by writing spaces. Verified with break.sh both ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
7dca141aae |
The vector's number goes beside its kind, not at the end of the line
Kind and Number together are what names a thing: Vector 16 and Device 32 are identifiers in a way that Vector on its own is not. Everything after them - where it lives, what it is called, where it was written - says something about it rather than naming it, so with the number at the far end a line opened with a bare kind and closed with the fact that would have told you what you were reading. Fields are now Kind, Number, Address, Name, File, Line. A label still carries a dash where its number would be, which reads as "this kind is not numbered" rather than as a field that went missing. Manual and the docs check follow. Verified with break.sh by swapping the number and the address back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
f3d8985bc4 |
Vectors in the symbol table, with both of the numbers they have
A vector is the one thing about a program that nothing else can tell
you. A pinned vector has its number in the source that pinned it, but a
vector the assembler numbered has that number nowhere at all - not in
the source, not in the binary in any form a reader can find. Until now
there was no way to learn that a vector became number 64.
It also cost two hops to follow by hand. The name in "SWI osPrintString"
is not the name of the routine that implements it, so finding the code
meant searching for the vector, reading the handler's name off the
Vector Segment, and searching again. A vector row now names the handler
and gives the line the two were tied together on.
Both numbers, at the user's asking, because neither can be worked out
from the other without knowing which table the vector is in: the Number
is what a program writes and the machine dispatches on, the Address is
where the handler's address is stored, base plus twice the number. The
slot is computed with the same expression the loader is given, so what
the table says and what gets written there cannot drift apart. A vector
a program only declares is listed too - that is how a program says which
vectors it calls, and how two programs can be checked against each other
for agreeing about a number.
A device has no name of its own, being named by the port it is plugged
into, so it is listed under its handler.
The first field is now Kind rather than Memory, because Vector and
Device are not memories. Sorted Program, Data, Vector, Device.
docs.sh checks the six fields against the manual and against real dumps
of two programs - Keys, a loadable program with all four kinds, and
cosmos, a boot image whose segments both start at zero. It now also
checks that a row's name really appears on the line the row names, which
is what catches the string-newline bug fixed in
|
||
|
|
3527812c41 |
A symbol table says which memory, and where the name was written
The dump was an address and a name. Both of the questions it gets asked were only half answered. "What is at this address" was ambiguous, because Program and Data are separate memories and an address alone does not say which one. That is easy to miss in a loadable program, where the segments are usually based far apart - and immediate in a boot image, where both start at zero: replCalculator has a Program 0003 and a Data 0003 and the old file printed both as "0003 <name>". "Where is this defined" was not answered at all, and it is the one that matters more as a program grows. A name defined once and called in forty places is hard to find by searching. Lander's table names five files besides its own; CosmOS and its libraries define over a thousand names across a dozen. So: memory, address, name, file, line, separated by tabs, sorted by memory and then address with Program first. Tabs because that makes it a table cut, awk and sort already read, and no heading line because nothing should have to know to skip one. Everything needed was already being passed to addLabel and thrown away; the file name points at the copy the include list owns, which outlives the label table. The manual describes the five fields, and docs.sh now settles that description against a real dump - the shape, not the values, so that an example cannot go stale and turn editing a program into editing a manual. Verified with break.sh three ways: a reordered field, a dropped field, and a field renamed in the manual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
7a55cfe151 |
Say that an option's file is one the assembler writes, and check we said it
-S was added without a row in the Assembler Manual, and the usage it printed listed a bare "-S <file>" with no long name and no statement of what the file is for. That is not merely incomplete, it is misleading: "-S <file>" reads just as naturally as "dump the symbols of <file>", and asking for it that way hands the source to -S, leaves nothing positional behind it, and is answered with "No source file specified" on a command line that plainly names a source. The error described the hole the mistake left and hid the mistake. So the usage now prints the long names, says outright that every <file> is a path it writes and the source is the last argument on its own, and ends with a whole example command. When the source is missing and a file-taking option was given, the error says which options take a path to write. The manual gains the -S row it never had, a warning in the same words, and a sentence on what a symbol dump is for. Documenting it twice is how it went wrong once, so docs.sh now settles both against getopt's own option table: every option the assembler takes has a row in the manual and a line in its own usage. Verified with break.sh against the manual row and the usage line separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
cdee9acfae |
break.sh believes a suite's exit status, not the shape of its output
It decided whether a suite noticed by grepping its output for a "N passed, M failed" summary line. That is right for the suites that print one, since they print it only when something failed - and impossible for the three that never print one at all. docs, terminal and voyager report in their own words, so a break any of them caught loudly was reported as "NOTHING CAUGHT THE BREAK". That is the one wrong answer the tool exists never to give, and it was turning up in a third place: the header already tells the story of the first two. It made the whole docs suite unverifiable by the harness the project uses to decide whether a check is worth having. Every suite already exits nonzero when it fails, so that is the signal now. The summary line is still printed where a suite keeps a count, and the suite's own last line stands in where it does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |