cdee9acfae0bbb674c6ce811ed79bfb059032a85
316
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e52d8d84f8 |
Collapse the two makefiles into one
Programs/makefile is gone and everything it did is here. The split was not paying for itself: nothing at this level ever ran that file, so it rotted, and all three bugs found in it this week share that one cause. It named two source files that had been renamed months earlier and failed outright. The disk did not depend on the tree it mirrors, so a new file silently was not on it. And the disk was in no default target, so 'make clean' threw it away and 'make' did not bring it back - which is what prompted this. The platform and the system stay separate, as TARGETS rather than as files: 'make SplitBit Assembler' builds the machine and its tools, 'make cosmos' and 'make disk' build the system, and somebody who wants to write their own system can ignore the second group entirely. That boundary is now one make enforces, which the directory boundary never did - Tests/makedisks.sh has always reached across it to build its own fixtures. 'make' now builds a bootable disk as well as the tools, because a machine with nothing in the drive does not do anything and the first thing anybody wants after building this is to watch CosmOS come up. It costs half a second: the whole system, twenty six apps and the native assembler assemble in less time than the emulator links. Two things the merge needed that the split did not. Assembling now takes an order-only dependency on the assembler, which came free when you had already built the tools before changing directory. And the mirror is told which directory to walk: it was ".", meaning Programs/, and left alone it would have meant the whole repository - the C sources, the tests and the manuals mirrored onto a disk for an 8-bit machine. The disk comes out at 188 files where it was 189. The one that went is Programs/makefile, which was itself being mirrored onto it as /Source/makefile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
2f22807458 |
Give the demo a real tune: the Anachronaut Labs theme
The composition is the user's - a leitmotif they already have variants of, written into the note table by hand. It replaces the eight note scale that was there to prove a note could be played at all, and it is a better demo for the obvious reason and one less obvious one: it is long enough to hear whether the machine keeps time, which a run of eight notes is not. Forty five notes, 567 frames, 9.45 seconds, peaking at 19,461 of 32,767. The test budget goes to twelve million cycles, which is a duration rather than a guess: at 16,667 cycles a frame the music is 9.45 million, so there is room to add bars before anybody has to come back here. Also records what borrowing the screen's frame actually costs, which came out of writing music rather than out of theory. The frame is not too slow, it is FIXED: every duration is a whole number of 16.67 ms, so a note worth a third of a beat cannot be written, and the way round it is choosing a tempo whose subdivisions land on whole frames - making the tune fit the machine. That is the argument for the timer peripheral wanting an arbitrary tick rather than a faster fixed one, and for the screen not being the clock: a display refresh and a music routine have no reason to share a rate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
85329f13c3 |
Take a device's line down when its status port is read
A device raises a line and something has to take it down. Two things did: being interrupted, and being woken from WAIT with the Interrupt Flag down - the second because a masked program has nowhere to dispatch to, so nobody else would. There was a third way to learn a device had finished and nothing answered it. The documented idiom reads the status, branches out if the device is already done, and only WAITs otherwise; on a disk quick enough to finish before the first look, which is every disk here, the WAIT is unreachable. The line then stood for the rest of the machine's life. The program that leaves it standing never pays for it - it was masked throughout. The bill arrives at whoever next sets the Interrupt Flag. The boot chain reads the disk to load a program, leaves the line up, and hands over; the loaded program is then interrupted on behalf of a read that finished before it existed, through a vector table with no entry for a device it never touched, and faults on the instruction after its SIF. Found by running Examples/tune.asm through Once. It set up its whole sound and died four bytes before its first note, which is why it was silent rather than wrong - and why it looked like a sound bug for a while. So reading the port that answers a device takes its line down, the same way taking the byte already took the console's down. Disk and screen do it on their status port. And a reset now clears every line, which is the sentence the manual already makes about the vector table: a handler left behind aims an interrupt into a program that is no longer running, and so does a line. testPrograms/diskLineTest.asm pins it - the racy idiom, then SIF with no handler installed anywhere. It faults without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
62a657f1a7 |
Give Voyager a speaker
The device already made the samples; this takes them out of its ring and hands them to Raylib, a sub-buffer at a time. Nothing here decides what a sound is, the same as nothing in presentFrame decides what the screen looks like - which is why the headless binary and this one make the same sound, and why the suite can check a device with no speaker in it. Asked for rather than assumed: a host with no audio device still gets a window, because a machine worth looking at should not need one to start. When the ring runs short the missing samples are filled by holding the last one rather than by zeroes. It is still a glitch; a jump to silence and back is a click, and a held level is not. Also removes a comment that had been left in twice above the frame loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
8366bf7721 |
Fix the Programs build, and make the disk depend on what it mirrors
Two faults, both introduced by
|
||
|
|
d388cd3122 |
Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.
Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.
Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.
Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:
- Only one voice had an oscillator switched on, so three of the four
channels could not make a sound whatever was written to them.
- That voice's oscillator arrived at full gain and every other one
arrived at nothing, an asymmetry with no reason behind it.
- A note with no sustain is silent but not over, so the obvious way to
wait for a sound to finish waits for ever.
The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.
Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.
Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
|
||
|
|
b0d06aa6e5 |
Make the vendored synth plain ASCII
soundThing's comments use em dashes and an arrow, and this repository is plain ASCII throughout because the tooling around it does not do Unicode. Tests/docs.sh caught it the first time it ran against the new file, which is what that check is for. Comments only; nothing the compiler sees has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
f58b0f93af |
Vendor the voice engine, with the level it always had and could not say
soundThing's synth.c and synth.h, and nothing else of it: the voice engine pulls in maths, stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the interface, which is what made it liftable. Copied rather than submoduled - two files against tying this build to another repository's history is not a close call - so the header carries the whole of the difference and is meant to stay current. A VOICE'S LEVEL IS A ROUTING NOW. Envelope 0 was multiplied into the output unconditionally, so routing it to a filter or an oscillator meant it shaped the volume as well whether that was wanted or not, which is most of the trouble with making percussion. There was no flag to turn that off and there never had been: Envelope is the only one of the four modulating things without an active flag, where LFO, Filter and Oscillator all have one. It could not take the filter's shape either. filterTick returning its input unchanged is the right nothing-happens answer, but an envelope returning a constant would be useless as a mod source - and being a mod source while NOT being the volume is exactly the thing wanted. So the voice names the source that shapes its level, the way every other destination names its source, and MOD_SOURCE_NONE means nothing shapes it. Two things fall out that were not possible before: envelope 1 shaping the volume, and an LFO doing it, which is tremolo. Envelope 0 still decides how long a voice lasts, because it still ticks. A voice with its level unrouted will therefore cut rather than fade when the envelope reaches idle - fine or wanted for percussion, a click if the oscillators are loud at that moment, and a one line change to end on note-off instead if it turns out to matter. NOISE COMES FROM A SEEDED GENERATOR, one to each thing that makes it. rand() is global state shared with the whole process and varies between C libraries, so the same program sounded different on different machines and no recorded result could have meant anything. One generator each rather than one shared, because two noise sources drawing the same stream are not two noise sources. Checked rather than assumed: two synths from the same start produce identical samples, and 4,617 of 4,800 of them are non-zero, so it is noise and not silence. AND A CHANNEL IS THE CHANNEL YOU ASKED FOR. synthNoteOn hunts for a free voice and steals round-robin, which is what a keyboard wants and what the standalone synthesizer still does. Channel two is channel two. Both old calls are untouched. Nothing links it yet. It compiles clean and standalone under -std=c11 -pedantic, which is what make strict already checks, and the 169 tests are unmoved because nothing calls it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
33afc20abc |
Give the memory controller to a bus rather than to the machine
The third and largest piece of the peripheral core, and like the two before it nothing changes: 169 tests, and the two that would notice a misroute pass loudest of all. A CONTROLLER IS THE MOST STATEFUL THING ON THIS MACHINE - a source bank and address, a destination, a length, five guard registers and a table describing every bank it can reach. Two processors sharing one would interleave into nonsense: one sets a source, the other sets a destination, the first issues a blit and moves the wrong bytes somewhere else again. No arbitration fixes that, because there is nothing to arbitrate - both writes were legal and the result belongs to neither of them. So it is a struct threaded through all seventeen functions that touch it, rather than a pointer to a current one set on the way in. The smaller change was tempting and is the shape that produced the stale reset flag and the editor's IsNew surviving a second run, both this week: state that has to be set on the right path before anything reads it. A controller is where that goes wrong quietly rather than loudly. THE STATICS WERE DELETED RATHER THAN LEFT, which is what made this safe to do mechanically. A missed reference is a compile error rather than a variable that still exists and serves the wrong core, so "did I catch all 165?" stopped being a judgement and became a question the compiler answered. Two things the transformation nearly got wrong, both caught by reading rather than by building. guardStart and guardEnd are fields of Bank as well as registers of the controller, so banks[n].guardStart had to keep its name while a bare guardStart changed - the difference between a fence and the register about to be written into one. And a definition and a call look alike enough that the first attempt turned publishBank(number) into publishBank(Controller *c, number); definitions start at column zero here and calls never do. Tests/agree.sh is what says this is right, more than the count does. It builds the same disk with SplitDisk and with CosmOS and compares byte for byte, and every filesystem operation on the machine goes through the controller - so a blit that went to the wrong place would corrupt a disk that is checked against one built by different code entirely. Tests/cycles.sh covers the other half, since what a transfer costs depends on pendingCycles landing in the right one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
5be5bea994 |
Make the interrupt lines belong to a bus rather than to the machine
The second piece of the peripheral core, and like the first it changes nothing: one array of bits became a struct, and the machine's own devices reach it through wrappers because every one of them really is on this bus. IT WAS ONE ARRAY INDEXED BY PORT FOR THE WHOLE MACHINE. With two processors that is not a tidiness problem, it is each of them seeing the other's lines: a disk finishing would interrupt a sound core, a sound core's tick would interrupt the shell, and both would arrive at a handler installed for something else entirely. Hardware vectors are per port, so the numbers would even look plausible on the way in. Unlike the shift register, which was harmless until there was a second core to share it with, this one was always going to be wrong the moment there was one. Still to come on this rung: a controller each, since it holds the source, the destination, the length and five guard registers, and two cores setting those between each other's instructions would interleave into nonsense. Then the interleaving rule, which belongs in the manual as machine behaviour rather than as something the emulator happens to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
4e3258e3f7 |
Put the libraries on the disk, and say which include is missing
Sieve-16.asm, Life.asm and Fib-16.asm could be read on the machine and not assembled on it. All three #Include print.asm, and print.asm was not on the disk. /LIB WAS STILL A HAND-WRITTEN LIST. Programs/Libraries is what an #Include means when it is not a CosmOS source - print, the integer helpers, the maths - and nobody had thought to name them, which is the exact failure mirroring the sources was meant to end. It is mirrored now, so the next one nobody thinks of is there anyway. AND THE ASSEMBLER SAID NOTHING USEFUL. A missing include returned a failure and printed no message, so the catch-all at the top said "nothing was written", which is true and useless - finding out why meant reading the assembler rather than the message. It names the file now, and where it looked: cannot find print.asm, not beside the file that asked and not as /Lib/print.asm Both halves are worth having, because a name that is in neither place is usually one or the other spelt wrong. The same shape as More's "error 2": a program that knows exactly what happened, reporting a number or nothing at all. The disk also depends on the recipe that lays it out now. It did not, so adding the libraries left an image built without them and the next run reported the same thing missing - which sends you looking at the change you just made rather than at the stale thing in front of you. It cost twenty minutes to notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
dae3455da0 |
Give the CPU a bus, so that there can be more than one
The first piece of the peripheral core, and it changes no behaviour: the machine still has exactly one processor, and every one of the 169 tests still passes. What changes is that the code has stopped assuming so. FIVE THINGS A CPU ASKS OF THE WORLD OUTSIDE ITSELF, and every one of them was a call to a function there was exactly one of: the port handlers, what the controller spent moving memory, what was spent stopped waiting on a device, and the two that work the interrupt lines. Fine for a machine with one processor and wrong for a machine with two, because A PERIPHERAL CORE'S BUS IS ITS OWN - it sees the devices its own device gives it, raises its own lines, and stalls on its own controller, none of which are the host's. They are gathered into a Bus that the CPU holds a pointer to, rather than threaded through as a bus number, because a bus is something a device PROVIDES. A device that provides one should hand over the answers rather than be looked up by an index somebody else has to keep right. initializeCPU puts the machine's own there, which is what every processor was on when there could only be one, so nothing at any call site moved. And shiftRegister is a local now. It always was one in effect - written and read inside a single instruction and never carried to the next - but it sat at file scope, where a second processor would have shared it. Two cores taking each other's shift halfway through an SHL is a poor thing to discover later, and it cost two braces to make impossible. Still to come on this rung: the interrupt bitmap, which is one file-scope array indexed by port for the whole machine; a controller each; and the interleaving rule, which has to be written into the manual as machine behaviour rather than left as something the emulator does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
f2e26c1852 |
Stop a pending reset outliving the reset it belonged to
Reset out of picture.bin and CosmOS booted and then halted at once, having been told there was nobody at the keyboard. The button set two things: the machine's reset request, and a flag of the window's own that said "end the next console read, so a machine blocked on a key can get to the point where it notices". The second was only cleared when the console actually asked - and picture.bin never asks. It draws and halts. So the flag survived the restart and answered the NEXT machine's first read with the end of input, which for CosmOS means stop. There is one fact and it now lives in one place. The window asks whether a reset is still waiting rather than remembering that it asked for one, so the read ends only while a restart is genuinely on its way and goes back to normal the moment it has happened. The local flag is gone. Two pieces of state meaning one thing, one of them cleared on a path the other did not need - which is the same shape as the console's line editing flag surviving a second run, a fortnight ago. Worth noticing twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
adefce975b |
Let the reset button reach a machine that has stopped
The gesture rebooted CosmOS and could not reboot picture.bin, which is the case it was added for. picture.asm ends in HALT, and a halted machine runs no instructions - so nothing ever reached the code that notices a reset, because a reset is noticed BETWEEN INSTRUCTIONS and there are none. It only ever worked because CosmOS was still going. Which is backwards: a machine that is not going anywhere is exactly the one worth restarting, and it is the one that cannot hear a request by itself. The restart is lifted out of the run loop into machineTakeReset, and the window asks every frame whether the machine is running or not. NAMED AS EMULATOR MAGIC, because it is. There is no reset line on this machine and no keyboard controller to assert one; the window reaches in and sets the same flag the machine port sets. When those are designed, a keyboard controller will have to see the gesture and pull reset regardless of what the CPU is doing - which is the property that matters and the one a port write can never have, since a port write needs a program willing and able to make it. The shape of that is already visible here: asking every frame rather than leaving it to the machine to notice is what a line does. A restart now clears the cycle limit as well, since a machine stopped for reaching one is another thing somebody would press the button over. The three existing reset tests still pass, and they are the ones that matter: they exercise the same restart through the machine port. What no test reaches is the gesture itself, which exists only when there is a window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
8c6ed62044 |
Reset on a gesture the host has no opinion about
Control, Shift and R. It was Control, Alt and Delete, which brings up a shutdown prompt rather than reaching the machine - and no amount of asking will change that. IT IS A SECURE ATTENTION KEY. Every serious operating system reserves it so that it always reaches the system and never an application, precisely so a program cannot imitate a login screen. On Windows an application cannot see it without a kernel driver; on Linux the desktop takes it. That is not an oversight to work around, it is the same guarantee a reset button wants being enforced one layer further down, and there is no call this program could make that would win the argument. It is unavailable for exactly the reason it seemed right. So the requirement list grew a third item that was not obvious until it bit: the gesture has to be one the host has no opinion about. Control and Shift with a letter is about as free as a combination gets - not window management, not a virtual terminal switch, and not a shortcut any desktop claims by default. It keeps the other two properties: plain F12 and plain Delete stay free for software, and three keys are not pressed by accident. If some platform does send a character for it, nothing comes of that either. Whatever arrives is in memory that is about to be thrown away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
0afead00de |
Make the reset a gesture rather than a key
Control, Alt and Delete, which was F12. Two reasons, and the first one is about hardware rather than about the emulator. ON REAL HARDWARE THIS IS NOT A KEY AT ALL. A Voyager has a button on the case, and what a window has instead of a case is a gesture standing in for one. So it must not be a key software might want - a machine with a keyboard has function keys on it and something will eventually have a use for F12, which would have meant taking it away again later, from programs already written to expect it. And it must not be reachable by accident. Restarting throws away everything in memory, which is the same data loss Escape was one keypress from causing, arriving by a different door. Three keys together are not pressed by mistake. It has meant this since 1981, so it is also the one gesture nobody has to be told the meaning of - and Delete stays free for software, since the gesture is all three or nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
f7657081be |
Put a reset button on the case, and stop Escape closing the window
ESCAPE WAS A BUG I LEFT. This machine sends Escape to the console like any other key, and Raylib closes a window on Escape unless it is told not to - so a program reading keys could be ended by one of them, taking whatever was in memory with it. SetExitKey(KEY_NULL), and it is a byte again. F12 is the reset button. A button on the case rather than a key the machine can see: nothing sends a function key to the console, so nothing can be surprised by one. It does what writing MACHINE_RESET does, which is that the machine starts the way it started - the boot chain runs again and finds whatever the disk now says to run. Which is what makes a bare metal program escapable. Once puts a demo in front of the next start and deletes the request before jumping, so a demo that has taken the whole machine is one keypress from the system coming back, instead of closing the window and opening it again. IT HAD TO REACH A MACHINE THAT IS WAITING, and that took two more things. A reset is acted on between instructions, and a machine blocked on a key is part way through one - so the button would have set a flag that nothing ever came along to notice, in exactly the situation a reset button is for. The wait ends now: the console is told its input is over, which it is for a machine about to stop existing. And the reset puts the console's input back - nothing pushed back, no line half gathered, and not at the end of input. That was already wrong before the button existed: a reset after the input ran out left a console that had run out afterwards, so a machine could be restarted once and then never typed at again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
13b20c8834 |
Give the screen a bitmap mode
V4. Mode 2 is 320 by 200 with a byte a pixel: no tile to look up and no attribute to add, the byte IS the palette index. Programs/Examples/picture.asm fills a whole one in 127 bytes of program and 47,498 cycles. IT IS THE SAME MEMORY AS THE TILES AND THE MAP, which is what shared video memory has always been, and there is nowhere else it could be - 64,000 bytes of picture in a 65,536 byte bank leaves room for nothing beside it. Going to bitmap mode does not clear the text screen, it stops calling it one, and coming back finds the tiles holding whatever the picture put there. Taking the screen means taking it. The palette moves to 0xFC00, the top of video memory, because it is the one thing that has to mean the same in every mode and 64,000 bytes of picture leaves nowhere in the middle for it to hide. That is a documented address, so the example, the tests and the manual move with it. A BITMAP HAS NO COLUMNS AND NO ROWS, and both registers read zero rather than a leftover from the last mode. The console asks: told there is no character screen, it has nowhere to put a glyph and draws nothing, while still saying everything down the serial line. The honest alternative is what a machine with shared video memory really does, which is scribble marks nobody can read across somebody's picture - honest and useless, since a program that has taken the screen has not stopped wanting to print. Six checks in Tests/video.sh, to 55: that the mode is 320 by 200, that a byte is one pixel's colour and only that pixel, that printing leaves a picture alone while the letter still goes out, and that the columns register says nought and then forty again. The example is worth reading for one thing beyond the mode: Fill leaves its destination past what it touched, so two hundred rows are drawn from one address set once. Working out where row n begins would be n times 320, and this machine has no multiply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
1174bd9af5 |
Give the machine a frame to wait for
V3. The screen interrupts at each frame on hardware vector 0x30, and WAIT finally has something worth sleeping on. THERE WAS NO CLOCK. Every program that wanted to happen at a certain speed counted instructions and hoped, which is why Snake's pause silently halved the day a cycle stopped being an instruction and became a memory access - the program was right and the thing it was counting changed underneath it. A screen finishing sixty times a second is a real beat, and it is counted in the MACHINE'S cycles rather than the host's, so the same program sees the same number of frames in the same number of cycles however fast anything really ran. That is what makes a frame something a test can count and a recorded result can hold. Status bit 0 goes up when a frame has gone by and reading the status port puts it down, so a program with no handler can watch for it instead. Control bit 0 asks to be interrupted, and is OFF when the machine starts: an interrupt with nothing installed to catch it is a fault, so a screen that began interrupting the moment it was switched on would take down every program written before frames existed. More than one frame can pass between two looks, and the flag and the line are each one thing, so several still mean one of each. A missed frame is missed. Programs/Examples/frames.asm prints a dot a frame for a second: 1,000,324 cycles, and 996,460 of them spent asleep. That split is the thing worth seeing - a program that polled instead would print the same sixty dots, take the same second, and spend every cycle of it on the bus. Its header explains why waiting is not spinning and why a machine with a beat can stop guessing at one. Six checks in Tests/video.sh, and two of them are about the clock rather than the output, because the output cannot tell the difference. That the machine slept through nearly all of ten frames, and that polling three frames actually took three frames - a status flag that stayed up once set would print exactly the same character and look perfectly correct. Breaking the frame interrupt on purpose left a machine asleep for ever and hung the whole suite, which is a worse way to be told than a failing check. Tests/video.sh bounds its runs at ten seconds now, the way Tests/run.sh always has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
3da020898c |
Write down what the machine costs, before it has to be worked out again
Three measurements that would otherwise be re-derived from scratch in three months, put where a reader will meet them rather than where somebody has to know to look. The README's cost model section says what a nominal 1 MHz actually means: about 270,000 instructions a second, since the average instruction costs 3.72 cycles. The same program therefore takes 3.72 times the wall clock it did when a cycle was an instruction - nothing got slower, the number got honest, and the number it replaced described no machine anybody could build. Beside it, the two figures that put that in proportion: the emulator runs 195 million cycles a second in --fast, and CosmOS assembling CosmOS is 654 million of them, which is eleven minutes at a megahertz, 3.3 seconds at --fast, and six and a half seconds at a hypothetical 100 MHz. That last is pessimistic rather than hopeful: it assumes hardware overlaps nothing, which is the same conservative reading the controller's cost model takes. The CosmOS README gets what that means for its assembler, which is superlinear in what it reads: 1,383 cycles a byte for a four kilobyte source, 6,290 for a hundred kilobyte one. IT IS NOT THE DISK, and the number that says so is worth keeping - 654 million on a disk carrying the whole source tree against 653 million on a flat one with a sixth as many files. The suspected cause is walking a table of some nine hundred labels once per reference, suspected rather than measured. Nothing is being done about it, and the reason is written down too: development happens with the host assembler. But faster hardware buys a constant factor and does not change the shape of the curve, so the program that forces this is not CosmOS - it is the first one twice its size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
5732a31b2e |
Stop the allocator starting the directory again for every file in its way
Placing one file on a disk with the source tree on it cost 9.4 million cycles. It costs 1.4 million now, and assembling colours.asm went from 13.9 to 6.0 seconds. sbfsAllocate gave up the moment it found anything in the candidate's way: it moved the candidate past that one entry and STARTED THE DIRECTORY AGAIN FROM THE FIRST BLOCK. With files laid down one after another that is a restart per file, and every restart reads directory blocks off the disk until it reaches the next thing in the way - which is further in each time. Placing one file among 183 of them cost thousands of block reads. The candidate moves along DURING the pass now, and the pass carries on from where it is, so entries later in the directory are tested against where the candidate has got to. On a disk that has been appended to - which is what a disk mostly is - one pass walks it past everything and a second confirms nothing is left. Two passes rather than one per file. IT IS STILL FIRST FIT, and Tests/agree.sh is what says so: the machine and SplitDisk build the same tree and the images still match byte for byte, which they could not if allocation had started choosing differently. The argument is that the candidate only ever moves past something that genuinely overlaps it, and when it does there is nowhere below to go - the entry in the way covers everything up to its end and begins before the candidate ends. The first attempt at this was slower than what it replaced, by three times. It finished the pass and jumped to the FURTHEST overlap, which sounds better and is worse: with files laid contiguously only one entry ever overlaps, so the old early exit was the fast path and reading the whole directory to find the one thing was pure loss. The number of passes was never the thing to fix - restarting them was. The boot slot in the test fixtures goes from 32 blocks to 40, which is what a shipped disk has. Stage two is 8,231 bytes and 32 blocks is 8,192: a fixture tighter than the thing it stands in for fails on a change the real disk would have taken, and says "the boot slot is too small" rather than what actually grew. WHAT THIS DOES NOT FIX is assembling CosmOS, and that is worth saying plainly. It takes 654 million cycles on the mirrored disk and 653 million on a flat test disk with a sixth as many files, so it is not a filesystem problem at all. Cycles per byte of source climb with the size of it - 1,383 for colours.asm, about 3,000 for Edit.asm, 6,290 for cosmos.asm - which says the native assembler is superlinear in what it reads. That is a separate thing to go and look at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
7073b972e6 |
Say what went wrong, and give the file tools room for a path
The makefile on the disk was fine. "Makefile" is not "makefile", and SBFS names are case sensitive - but neither tool said so, and both failed in ways that pointed somewhere else. MORE PRINTED A NUMBER THAT MEANT NOTHING. "cannot find the file, error 2" invents a vocabulary the system does not have: the filesystem library documents its answer as zero or not zero, never as a code, so 2 could not be looked up anywhere. It says "there is no file by that name" now, which is the only way opening fails that a person can do anything about, and is nearly always a name typed slightly wrong. EDIT SAID "0 LINES", which is also what an empty file that IS on the disk says. A name typed slightly wrong therefore looked exactly like the document you meant to open, right up until you saved it somewhere new. It says "new file" instead. Two bugs came out of writing that, and both are worth more than the feature. The first is mine and the label lied to me: loadNothing is not where a load FAILS, it is where every load FINISHES, reached at the end of splitLast on files that opened perfectly well. A flag set there on the strength of the name was set on everything. It is called loadDone now, and the failure has its own name. The second is older and general: a program is loaded once and may be run many times, so "load Edit.sbx" then "run" twice is two sessions over one copy of the Data Segment. Anything a session changes has to be put back by the session. A zero written in the Data Segment is the state a program starts in the first time and never again - and cosmosEdit runs Edit twice from one load, which is why it caught it immediately. AND THE FILE TOOLS COULD NOT ADDRESS THE TREE THEY NOW HAVE. Edit took 23 characters of name and More and Type took 29, which were right when everything lived in the root. With the sources mirrored onto the disk, "/Source/CosmOS/Assembler/classify.asm" is an ordinary thing to type - thirty-seven characters, cut down to a name meaning something else, or nothing. All of them take sixty-three now, which is what the shell reads of a command line, so nothing longer can arrive. Wander with it, since a directory is a path too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
0852666e73 |
Mirror the source tree onto the system disk
A list of files in a makefile goes stale the moment somebody adds a program and forgets to name it, and what they forgot is invisible until they go looking for it on the machine. So SplitDisk gained a mirror command and the disk rule is one line: putting a file where the others live is now the whole of putting it on the disk. EVERY FILE GOES THROUGH put AND EVERY DIRECTORY THROUGH mkdir. That is the point of it - mirror adds a walk and no filesystem code at all, so anything the format refuses here it refuses everywhere, in the same words. What is new is the walk, and the walk is what the six checks in Tests/disk.sh are about: that it goes all the way down, that it leaves dotfiles and named directories behind, and that a name too long stops it. REFUSED RATHER THAN SKIPPED, because a disk quietly missing a file is the exact failure a mirror exists to prevent. Which meant four sources had to be renamed - a directory entry holds 22 characters and they were 23, 23, 24 and 29: 16bitSegmentedSieve.asm -> 16bitSieve.asm 16bitSegmentedSieveModern.asm -> 16bitSieveModern.asm consoleInterruptTest.asm -> consoleInterrupt.asm controllerWriteTest.asm -> controllerWrite.asm The test names in the manifest are unchanged, since those are identifiers and every recorded result is filed under them. Only where the source lives has moved. The entries are sorted before anything is written. readdir hands them back in whatever order the host filesystem feels like, and a disk image that comes out different from one run to the next is an image no test could compare against another. The disk grew from one megabyte to four and from 192 directory entries to 1,024. The sources are 2,850 blocks and the mirror filled the old directory on its first run, which is a thing that should not need thinking about again. The Tests fixture disk is deliberately NOT mirrored. It is a controlled fixture with known contents, and the shipped disk is the one meant to be useful; they want different things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
ff4b025058 |
Make the cursor blink while the machine is waiting, and show how the palette works
THE CURSOR DID NOT BLINK, and the reason is worth stating: it blinks on the machine's clock, and the machine's clock had stopped. A console waiting on a key stops the CPU, so no cycles passed, so the phase never moved - and the one moment somebody is looking at a cursor is the moment they are being asked to type. Waiting is now charged as IDLE CYCLES, which is what they were built for: a machine stopped on a device is not using memory, the same distinction WAIT makes, arrived at from the other direction. And the devices are told as it happens rather than when the instruction finally finishes, because a display controller does not stop blinking because the processor is waiting on a keyboard, any more than a disk stops turning. A keyboard file can now say NOTHING happened. A zero is a byte no keyboard sends, so it is free to mean "a moment went by with nobody typing" - which is the commonest thing behind a window and the only thing a file otherwise could not express. That unlocked the whole waiting path: three checks that the cursor is lit, then dark half a second later, then lit again, which is what blinking is. And Programs/Examples/colours.asm, because the palette had nowhere a newcomer could read it. It prints the sixteen pairs, prints each one again turned inside out, and then CHANGES ONE by writing three bytes into the palette - so the difference between using the colours a machine wakes up with and choosing your own is visible in one program. Its header explains what a cell is, what the attribute nibble does, why palette entries are four bytes rather than three, and why video memory has to be reached through the controller. The manual now says where the palette lives and points at it. SplitLint found a redundant RSTA in the example, which was worth acting on rather than suppressing: the zero was already in A from the mode write two lines up, and saying so in a comment teaches that SETD does not touch A, which is a thing worth knowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
d6feddd1b6 |
Give the console colour and a cursor
COLOUR COSTS A NIBBLE AND NO HARDWARE. A glyph is drawn in palette indices 0 and 1, paper and ink, and a cell's attribute nibble adds sixteen to both - so sixteen banks is already sixteen ink and paper pairs, and all that was missing was a register saying which one the console draws in. That is port 0x06, read as well as written like the rest. The palette a machine wakes up with is arranged so that HIGHLIGHTING IS ONE BIT: banks 0 to 7 are colours on black, banks 8 to 15 are the same colours as paper with black ink. So attribute XOR 8 turns any pair inside out. That is a convention rather than a rule of the machine - the device only ever adds the nibble and looks the answer up - but it is the convention that makes a highlighted line and a cursor free. Bank 0 is still grey on black, so nothing that was written before this has changed colour. THE CURSOR IS THE SAME BIT AGAIN. It is drawn by turning its cell inside out rather than by putting a block over it, so the character underneath stays readable, which matters to somebody editing a line. The device draws it rather than the window, because on a machine with a screen a cursor is a hardware feature - one drawn by the presenter would not be in a picture the machine saved. It blinks on the machine's own clock, half a second each way, so the phase is a pure function of the cycle count and a screen saved at a given cycle is the same screen every time. A blink on the host's clock would have made every saved picture a matter of luck. Off unless asked for, with bit 2 of the control port. That is right for a machine - a program painting its own screen does not want something blinking in the middle of it - and CosmOS asks for one at boot. It also asks again when it takes the console back from a program that has stopped, because a program handing key mode back the way it was told to writes zero, which turns the cursor off. The shell owns the prompt, so the shell is what makes sure there is something blinking at it. Nine more checks in Tests/video.sh, to 41: that the attribute colours the ink and not the paper, that XOR 8 turns both, that it reads back, that a cursor appears where the registers put it and only when asked for, and that it goes dark again half a million cycles later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
978aec4809 |
Let the console edit a line, and let a file be a keyboard
BACKSPACE REACHED THE SHELL. A terminal in line mode does not hand a program every keystroke: it collects a line, rubs out a backspace, and delivers the finished thing at Return. CosmOS has always relied on that, and behind a window there is no terminal to do it, so the raw 0x08 went into the command buffer. Correcting a typo produced a line that looked perfectly right on the screen and matched no command at all - "I do not know: help". So the console does it, because behind a window the console IS the terminal. In key mode it does not, and must not: a program in key mode asked for every keystroke as it happens. CosmOS now asks for eighty columns at boot. Its own help text is seventy-four characters wide, and dir, the monitor and the assembler's messages all assume room. The machine still wakes up in the smaller mode, which is right for a machine - it is the system that knows what shape of screen its own output needs, and a game that wants forty columns says so. AND A FILE CAN BE A KEYBOARD, which is the part that matters beyond today. The console behind a window is not the console behind a terminal, and until now the difference was unreachable: it broke twice in two days and a person typing found it both times. --keyboard installs the same hook a window does, so the same path runs, and the manifest has a column for it. cosmosTyped types "halp", backs over it, arrives at "help", and requires the help to come out. Verified by removing the rub-out, which loses the whole help text. It does not test the window. Voyager's key queue and everything about presenting frames are still out of reach. It tests the console, which is where the logic is. Along the way: VOY_OBJS was missing from the dependency include, so voyager.o never rebuilt when a header changed. EmulatorOptions grew a field, Voyager kept an object that disagreed about the size of the struct, and smashed its stack on every run. A clean build hides it and 'make sanitize' cleans first, so that would never have found it either. Tests/voyager.sh did, by failing all 115 tests that start the machine - which is the differential test earning its keep on a bug that has nothing to do with what it was built to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
761c11a66b |
Add run-voyager, and make running depend on the disk
The jitter was a stale disk. An image built before the escape sequences came out still had the old Snake on it, which sends ESC [ H every frame: the console swallows the escape, because it is below the font's first character, and then draws "[H" as two ordinary letters. So every frame began two characters to the right and one line further down than the last, and the board walked down the screen. Not timing at all. WHAT IS ON A DISK IS WHATEVER WAS BUILT WHEN THE DISK WAS MADE, and a machine whose console has changed will start that image quite happily. That is the compatibility break we chose when the parser came out, and it is fine - but it should not be a puzzle, so both run targets depend on the disk rather than merely using it, and both READMEs say why. run-voyager boots the same disk on the machine with a screen. It existed only as EMU=../Voyager in front of run-cosmos, which is not a thing anybody should have to know. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
310804e267 |
Give Snake back the speed its comment promised, and present in step with the display
Two things, one certain and one likely. THE CERTAIN ONE IS NOT THE WINDOW'S FAULT. Snake's pause loop said "at the emulated rate this is about an eighth of a second", and it was, when a cycle was one instruction. A cycle became one memory access, every loop in the machine got dearer, and this one silently doubled: the game has been running at half the speed it documents ever since, in a terminal as much as in a window. Measured rather than guessed - the inner loop is a DECA and a BNA, one byte and three, so four cycles a turn, and a whole run went from 3,848,610 cycles to 1,920,504 when the outer count came down from 256 to 122. Almost exactly half, which is what the arithmetic said it would be. That is the cost model change reaching a program nobody thought to re-measure. Worth looking for others: any loop tuned by eye before that change is running at half its intended speed. THE LIKELY ONE is the frame limiter. Without the vsync hint, Raylib sleeps towards sixty frames a second on its own clock, which beats against a display refreshing on its own - frames shown twice or skipped, and the machine handed an uneven number of cycles each time, since it takes its budget from the wall clock. The hint puts presentation in step with the screen. SetTargetFPS stays for a driver that ignores it. Snake is one byte bigger, because RSTB became INIB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
43a05b3df1 |
Replace the escape parser with cursor registers
The console had grown an ANSI parser, and that was the wrong shape. ANSI exists because a screen used to be on the other end of a serial line and a byte stream was the only channel there was. This screen is memory the program can already address, so reaching it by sending characters for a state machine to take apart is a middleman for something the machine does better - and it meant accepting an open protocol somebody else defines, in hardware, with no natural end to it. Everything else on this machine is registers. So the console gets three: cursor row at 0x03, cursor column at 0x04, and a command port at 0x05 where 1 clears the screen. Both cursor registers are READ as well as written, which is the thing an escape cannot do without sending a query and parsing a reply - a routine that wants to put the cursor back where it found it can now ask. Clearing is one command against a thousand cells walked one at a time. Snake and Life are smaller for it: 2,168 bytes to 2,163 and 1,410 to 1,396. A HOST TERMINAL STILL SPEAKS ANSI, and bridging to the host is the emulator's job, the same job it does reading standard input. So the escapes are now GENERATED, outbound, for the set this device chooses, rather than parsed inbound as though the machine were a terminal. The set cannot grow behind our backs because we are the ones saying it. The cursor is announced lazily, at the next character rather than at the register write, so setting a row and a column costs one sequence rather than two. The console's block widens from three ports to six, which registryTest noticed: it had been asking about port 0x05 precisely BECAUSE nothing was there, and the console had just moved in. Re-blessing it would have left it checking nothing, so it asks about 0x80 instead - clear of the console, the disk, the screen, the controller, and the sound device coming to 0x40. Six checks in Tests/video.sh swapped from the sequences to the registers, including that the cursor reads back and that one sent past the edge is clamped rather than refusing. Those checks also stopped counting bytes from the ends of a file, which had quietly started measuring an escape the moment the console began announcing the cursor. SplitLint caught the one thing worth catching in the port: the clear command leaves A at 1 and key mode is also 1, so the second load looks redundant. Acting on it would tie a console command to a console mode by coincidence, and break silently if either ever moved, so it is suppressed with that reason rather than removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
bdb2d0d8e6 |
Keep a key until it is asked for
Input worked at the shell and not in Snake, and that split is the whole diagnosis: the shell blocks on a read, Snake polls the READY bit. Only the polling path was broken. RAYLIB CLEARS ITS CHARACTER QUEUE ON EVERY POLL, and a poll happens inside EndDrawing, so a key survives exactly one frame unless something takes it in that frame. The window presents sixty times a second and Snake looks about ten, so five keys in six were thrown away by the next present before the game ever glanced at them. A blocking read presented and then looked immediately, which is why the shell never noticed. The window now keeps its own queue, drained from Raylib at every present and emptied only when the console actually takes a byte. That is what this machine already promises about its console, and Snake's own comment is the specification: "the console keeps the next key until it is asked for, so a key pressed while the snake was moving is still there next frame". The hook was not honouring it. Asking the queue first also makes the two questions the same question. A poll takes whatever is waiting and returns at once, and a blocking read takes whatever is waiting, then presents a frame and looks again - so neither path can see a key the other would have missed. The queue drops its oldest when it fills, so somebody leaning on the keyboard while a program ignores it cannot push out what they typed most recently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
556a14b288 |
Teach the console the sequences the corpus already speaks, and let the status port see the window
Three things Snake found the moment somebody ran it in a window, and all three are the same kind of mistake: the console grew a screen and kept asking the terminal. IT COULD NOT CLEAR THE SCREEN. Every program here that moves a cursor does it with ANSI escapes, because until there was a screen the thing on the other end was somebody's terminal. The controller drew "[2J" as three letters and left the board underneath. It now parses them, which is what a video terminal did - a VT100 is exactly this. The whole corpus uses two, ESC[2J and ESC[H, and the general shape is recognised so anything else is swallowed rather than drawn: a sequence nobody implemented should leave no marks. Cursor positioning is in too, since it is the same parse and one line more. IT DID NOT SEE KEYS FROM THE WINDOW, but did when the terminal behind it was focused, which is the whole diagnosis in one sentence. Snake polls the READY bit and never blocks, and consoleFetch - what the status port asks - was polling standard input regardless of whether a front end had installed a hook. So a window's keys were invisible to every program that looks before it reads, and a keystroke aimed at the terminal would be picked up instead. The hook now takes a question. Zero is the status port looking, and must not present or sleep: a program polling in a loop would otherwise be charged a frame for every glance. One is the data port blocking, where presenting is exactly right, because a machine waiting for a key is still a machine somebody is looking at. One value for both would have made either polling ruinous or waiting dead. AND IT RAN SLOWLY, which was the same bug wearing a hat: a game that never receives a steering key is a game that only ever goes one way. Six more checks in Tests/video.sh, to 32: that ESC[2J clears, that ESC[H goes to the corner without disturbing what is drawn, that ESC[3;5H counts rows and columns from one, and that an unknown sequence is swallowed and leaves nothing behind. The hook itself is still the one thing here the suite cannot reach - it exists only when there is a window, and this host has no display. It was found by a person playing Snake, which is where the Test Manual says these go on being found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
6f8ad42277 |
Fill the window, let it be resized, and make black black
Three things a person looking at a real screen found in five minutes, none of which the headless tests could have seen. THE PICTURE FILLED A QUARTER OF THE WINDOW. The window opened at the largest screen the device can make, doubled, and then drew a 320 by 200 mode at that same doubling - so three quarters of it was bezel. It now takes the largest whole-number scale that fits: the two modes are exactly a factor of two apart and the window opens at twice the larger, so both fill it exactly, at four and at two. Changing mode changes how sharp the screen is rather than how big it is. Whole numbers on purpose. A 320 by 200 picture stretched by 2.7 has some rows twice as tall as their neighbours, which on eight pixel glyphs is the difference between text and mush. THE WINDOW WOULD NOT RESIZE. It does now, and the picture rescales to whatever it becomes, still in whole pixels and still centred. How big somebody wants a screen is not the machine's business. AND BLACK WAS NOT BLACK. Both the paper and the bezel were tinted towards green, on the theory that a phosphor never was neutral. On a real screen that reads as a fault rather than as character: a background that is nearly black looks like a background that failed to be black. Paper is black now and ink is a neutral grey, because a default should be the unsurprising thing - anything with a point of view about colour is 254 palette entries away and belongs to a program. The bezel is a clearly lighter grey, so what is left over when the window's shape does not match the picture's looks like a bezel rather than like more screen. The two checks that name the waking colours name the new ones. That they had to change is the check working: they say what they depend on rather than assuming it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
773b0f8add |
Put CosmOS on the screen without changing a line of it
The console is now a display controller as well as a port: it owns a font, keeps a cursor, handles newline, carriage return, backspace and wrapping, and scrolls. That is an ordinary kind of chip - it is what a video terminal's character generator did - and it is the reason this rung needed no changes to CosmOS at all. CosmOS already writes bytes to port 0x00. It writes to BOTH the screen and standard output, which is deliberate. A machine with a screen and a serial line is an ordinary machine, the emulator's standard output is that serial line, and one console drives both. It is also what keeps all 165 recorded results passing under Voyager, and what makes --screen work on the plain SplitBit: there is one console and it drives everything it has. Scrolling moves the video device's origin and no memory. The row arriving at the bottom is cleared because the map is a ring and it holds what was there 128 rows ago; the rows going off the top are not, and that is a hundred rows of scrollback nothing had to keep. The test reads the register back rather than looking at the screen, because a console blitting rows instead would look identical and cost twelve percent of a frame for every line printed. The font is vendored from Hatchet-GPU with a note saying where it came from, since that repository is not part of this one. 135 glyphs in ASCII order, which is the thing that makes it worth keeping - PETSCII's whole inconvenience was that its order was not ASCII's, so a machine using it needed a translation table in front of every string. Here the machine subtracts 32. It is stored one bit a pixel and expanded into tile memory at reset: 1,088 bytes against 16 kilobytes. Voyager gets a keyboard. A window has no standard input, and a machine blocking on it inside a frame would stop drawing and stop answering, so a front end with a window installs a hook that the console calls while it has nothing: it keeps the window alive and hands back a key. The hook has to tell "nobody has typed yet", which happens sixty times a second, apart from "the window has gone", which is the end of input - one value for both would have made the first keystroke look like a closed machine. In line mode the console echoes what it is given, because there is no terminal behind a window to do it and that was always the terminal's job. Tests/video.sh grew from 14 checks to 26, half of them about the console rather than the device: those programs ask the video device for nothing and write bytes to port 0x00 like every SplitBit program always has. Verified by breaking two things - removing the scroll failed exactly the two checks about scrolling, and removing the cursor advance failed exactly the three that depend on it. Two video checks had quietly depended on palette entry 0 being black, which stopped being true the moment a machine woke up able to show text. They now set what they are about to look at, and a new check pins the waking state itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
83623a3df3 |
Give the Voyager a screen
A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way the disk's buffer is. The CPU writes cell indices and the device turns them into pixels, which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles, a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that changes two cells writes four bytes. The cost of a screen becomes the number of cells that changed rather than the number of pixels on it. Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and each picks independently out of 256 colours, with no per-cell limit of the kind that made a Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the nibble at zero and gets them. Neither use costs the other anything. Two decisions are arithmetic rather than taste, and both come from the machine having no multiply. A map row is a page whether the mode fills it or not, so a cell address is the row number as the high byte and the doubled column as the low byte with no arithmetic at all; otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And a palette entry is four bytes rather than three, so entry n is at n times four, a shift. THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is twelve percent of a frame even with the controller widened, and a program printing one page would spend six frames shuffling memory. It is now one port write - and the rows that scrolled off are still there, which is where a terminal gets scrollback it never had. The device is part of the machine rather than part of the window. It renders into a buffer that is a pure function of video memory, so the same program draws the same picture with nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries take --screen, which saves a PPM when the machine stops, and that is what makes a screen checkable on a host with no display at all. Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image, because a recorded image would say "something changed" and leave which of the palette, the tile, the attribute, the map or the scroll register broke to be found by hand. Verified by breaking three things in turn: the additive nibble failed exactly one check, the scroll origin exactly two, and moving every cell one pixel sideways exactly the four about placement. Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported itself as wrong for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
e3ef25e3b3 |
Split the machine from its front end, and add Voyager
The Segan Voyager is the same SplitBit with a screen and a speaker instead of a terminal, and this is the rung that makes there be two of them at all. Everything that is actually the machine - the CPU, the controller, the devices, the run loop, the reporting - moves to machine.c, and each front end brings one file of its own. emulator.c is now sixty lines of argument handling and a three line loop. The machine runs in SLICES rather than to completion, because that is the cut a window needs: run a slice, present a frame, run another. A terminal runs slices until the machine stops. Both loops are three lines, which is why the cut is there rather than anywhere else. At this stage Voyager's window is empty. There is no video device yet and inventing a temporary way to draw would mean building something to throw away. PLAIN MAKE STILL WORKS WITH NO GRAPHICS LIBRARY. Raylib is probed by compiling and linking against it rather than by looking for a file, because a header with no library behind it passes a file check and then fails at link time. Where it is missing, make says so once and builds everything else - the machine, the assembler, the disk tool, the linter and the whole suite. A project about a small understandable CPU should not need OpenGL to run its tests. That nearly broke here: make strict globs Source/Emulator/*.c, so it would have tried to compile voyager.c and failed on precisely the machines the split exists to support, and this machine has Raylib so nothing would have caught it. Tests/voyager.sh runs the WHOLE MANIFEST through Voyager and holds it to the recorded results SplitBit is held to. Not that the two look alike: that one satisfies every recording the other does, byte for byte, exit status included. It reuses run.sh, which now takes the machine from SPLITBIT_EMULATOR, rather than keeping a second copy of the runner that would drift. Voyager not being built is not a failure - it says so and passes. Verified both ways. Made Voyager print one extra line, and 114 of 165 failed: exactly the tests that run the emulator, with the 51 assemble-only and xfail cases correctly untouched. Removed the binary, and the script skipped. Built with HAVE_RAYLIB=no, and everything else still built and checked clean. --headless is taken out of the arguments in voyager.c rather than in the shared parser, which should not learn about a window only one binary has. It exists so the suite can run this binary at all: a front end that could only be exercised by a person looking at it would be a front end nothing checks. loadFile takes a const char * now, which it always should have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
4c3eac8d9c |
Widen the memory controller's path to sixteen bits
The controller now reaches bank memory two bytes at a time, so a transfer whose source, destination and length are all even moves two bytes a cycle between banks and one within a bank - twice what each was. A 256 byte block between banks falls from 257 cycles to 129. Alignment is required all three ways because a word is read at an even address and written at an even address; an odd anything would mean shifting bytes across word boundaries to line them up, which is a different design. A misaligned transfer falls back to the byte a cycle it cost before, so nothing already written got slower. THE CPU DOES NOT CHANGE. It still sees eight bits, a Data Pointer still addresses a byte, and no instruction means anything different. This is a peripheral getting faster, which is why it is worth doing now rather than after more is built on top of it. The rule is deliberately visible rather than smoothed over: aligning a buffer costs nothing and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid. Tests/cycles.sh is new, and is the test the Test Manual has always said this kind of change would need - run.sh strips the cycle count from every recorded result, so nothing else in the suite can see any of this. It pins the RATE rather than a total: each case runs twice from programs whose instructions are identical but for the byte written to the Command port, once asking for the transfer and once for GuardOff, which costs nothing beyond the port write. The difference is the transfer and nothing else. Verified by disabling the widening, which failed exactly the three aligned cases and left the five misaligned ones passing. The Programming Manual gains a section saying what a transfer costs, which it never said at all - it only promised a transfer does not wait, which is a different claim and could be read as promising it is free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
c3c2451afe |
Say what the strict build and the sanitizers actually establish
Three claims in the Test Manual were true enough to pass and loose enough to mislead. The headline said the two assemblers produce identical bytes and the two filesystems produce identical disks, with no qualifier. That reads as universal and is not: it is evidence about the corpus assembled and the operations performed. The detailed sections already said so; the headline now says so too, because a document arguing about what evidence is worth cannot overstate its own. "Compiles under strict ISO C" omitted -D_XOPEN_SOURCE=700. The check is strict C11 with the POSIX interfaces the code uses explicitly selected - realpath, strdup, dirname and getopt - not freestanding ISO C, and the flag is part of the check rather than a hole in it. And the sanitizer section leaned on AddressSanitizer's junk fill, which is a toolchain default this build does not configure and which buys almost nothing here anyway: there are six heap allocations in the whole repository, all in the assembler, the largest a deliberate calloc, and the machine's own memories are static arrays the sanitizers neither fill nor bound-check. That last part is the overrun blind spot seen from the other side, so it now points at it. The same claim is corrected in the makefile, where it originated. Found by ChatGPT reviewing the manual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
8fbbeb6ec9 |
Make xfail compare the diagnostic, not just the failure
The Test Manual said an xfail test records the assembler's refusal message and so catches both an error that stops being detected and a message that changes without anybody meaning it to. It did not. run.sh checked only that the assembler exited non zero, printed the first line for a person to read, and compared nothing; --bless recorded nothing for these sixteen tests at all. So an xfail passed four different ways that look identical from outside: the intended error fired, an unrelated error fired, the message changed, or the assembler fell over on its way to the point. That is the documentation describing behaviour the code does not have, which is the exact failure Tests/docs.sh exists to prevent, in the manual that argues for knowing what your evidence is worth. The diagnostic is now stripped of colour, given the same [exit N] line every other recorded result carries, and compared through check() like anything else. Sixteen results recorded; every existing one is byte for byte unchanged. Verified the way the manual asks: one diagnostic was broken on purpose, its test failed with the changed line in the diff, and its neighbour passed. Found by ChatGPT reviewing the manual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
999451e935 |
Ignore External, which is other people's repositories
soundThing and Hatchet-GPU are kept in the working tree to be read, not built: one is where the synthesizer comes from, the other is where the 8x8 font and the scroll register idea come from. Neither belongs to this repository. They carry their own .git directories, their own build outputs and three compiled binaries between them, and a plain 'git add -A' would have taken all of it. What this repository takes from them will be vendored deliberately, with a note in the file saying where it came from and what changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
d6bc416698 |
Write the Test Manual, and make the suite check it
The test system had grown to seven scripts making five genuinely different kinds of claim, and nothing said which was which. A recorded transcript and a byte-for-byte comparison against a second implementation both print [ok ] and are worth wildly different amounts, so the fourth manual exists to say so: what each script can and cannot answer, why every determinism rule is there, how to add a test, and - the part written nowhere else - where the suite is blind. That last section is the reason for the document. Three buffer overruns into adjacent variables were all found by a person using the machine and none by the suite, the sanitizers cannot see them because emulated Data Memory is one legitimate host array, and there is no second opinion about the CPU at all. A document listing only strengths teaches the wrong lesson. The bullets describing each script move out of the README, so docs.sh now reads the manual for them, and five more numbers in it are settled from the source rather than trusted: the shape of the manifest, the xfail count, how many fixture disks makedisks.sh builds, how large the lint baseline is, and the tool count in either document. Each of the new checks was broken on purpose and watched to report before being kept, which is the discipline the manual itself argues for. Also drops the stale "70 instructions" from instructiontable.py's docstring. There are 72, and a number that carries no meaning is better removed than corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
79727044b7 |
Reboot, and the machine device that makes it possible
Until now the only way to restart was to stop the emulator and run it again, which meant the one thing the machine could not do was the thing Once was written for. The loop now closes without leaving it: > Once /System/Boot/bare.bin next start: /System/Boot/bare.bin, once > Reboot starting again stage two just this once: /System/Boot/bare.bin bare metal: no system, just this Writing 1 to port 0x13 asks the machine to start over. A PORT RATHER THAN A SERVICE, because a reset has to work when the system does not: something only askable through SWI would be unavailable in exactly the case that wants it most, and a program that owns the whole machine has no system to ask. It is device class 0x04, in the range kept for the machine rather than among the peripherals, because it is not one - it is not attached to anything and cannot be unplugged. WHAT A RESET REPEATS IS HOW THE MACHINE STARTED. Named an image, the emulator places it again; named none, the ROM is shadowed again and reads the disk. Anything else would mean a reset changed what the machine IS, which is the one thing a reset must not do. Both are tested. Taken between instructions, because a device cannot restart the machine from inside the instruction that asked: the CPU is part way through a step and its state is not yet anything a reset could leave behind consistently. The disk stays attached and keeps everything written to it - that is what warm means. The vector table is cleared, which is the one deliberate departure from leaving memory alone: a vector points into whatever installed it, and after a reset that program is not running, so a handler left behind would aim an interrupt at an address belonging to something gone. It is the argument CosmOS already makes at exit, applied to the machine. Reboot is 45 bytes, most of them the word it prints. |
||
|
|
7b28f48f52 |
Once: start something else on the next start, and only that one
A program that owns the whole machine had nowhere to run. It cannot be started from the shell, because starting it means there is no shell, and pointing boot.cfg at it means a machine that keeps starting it - which is a poor place to find a mistake in something written five minutes ago. Once writes /System/Boot/once.cfg, in the same format as boot.cfg and read with the same routines, because a second format for one setting would be a second format. The loader reads it before boot.cfg and DELETES IT BEFORE IT JUMPS, which is the only moment there is: after the jump the loader does not exist. Consumed by being read rather than by working, so a one shot that hangs cannot hang twice - the request is gone before the image ran, and the next start reads boot.cfg like any other. THE BOOT STATE IS NOT TOUCHED, and the first version got that wrong. It marked the start the way any other start is marked, and then every successful bare metal boot reported that it had never arrived - because a program with the whole machine has no filesystem to clear a mark with and is doing nothing wrong by not having one. Found by running it: the image printed its line and the next start still said the last one did not. Three disks, each a start further along, so none of the tests depends on another having run. The loop is closed on the machine now: write it in Edit, assemble it with Asm, ask for it with Once, restart, watch it own the machine, and the system comes back without being asked. |
||
|
|
89c667848b |
Edit read a file into a buffer it never checked the size of
Opening hello.asm showed a thirty one line file as three, one of them cut short. Opening it again hung the machine: the emulator kept running and nothing ever answered. Entry is the buffer a line is read into, and it is followed in memory by TextHead and ArenaFree - the head of the document, and the pointer its line allocator hands out. The loop that splits a file into lines copied characters in WITH NO BOUND AT ALL, so a 94 character line wrote thirteen bytes over both of them. The list head then pointed into the middle of the text and the allocator handed out an address inside the file, which is why the second open walked a list that led back into itself for ever. Typing was always safe. osReadLine is told how much room there is, so a new document behaved perfectly and a source file did not - which is exactly how the user found it, and why it looked like a mystery rather than a bug. The bound is there now, and the buffer is 128 characters: what a line is everywhere else on this machine, the same number configuration files use, rather than a second answer to a question already answered. hello.asm fits. A file with a longer line is REFUSED rather than shortened. This is an editor - a line cut on the way in would be written back cut, and the file damaged by having been looked at. It says so and exits with a status of one, which it can do since this afternoon; the file is byte identical afterwards, and the test checks that. Opened twice in the test, because once is not enough to see it: the first open does the damage and the second is what never returns. This is the third time this shape has turned up: a buffer written past its end into the variables that happened to follow it. The prompt walked off CwdText into the shell's own command names; the assembler's output ran into its label table. Every one was found by a person using the machine. |
||
|
|
87d819847e |
A program can say how it went
SWI osExit takes a status in A, and the shell keeps it. Fifty eight exits across twenty three programs now say deliberately whether they worked: 25 did what they were asked, 24 did not, 9 were asked wrongly. Compare is the exception and says so - one there means the files differ, which is a result rather than a failure, the way diff has always had it. IN A RATHER THAN Q, which is not a departure from the rule that a service answers in Q. This one takes an ARGUMENT, the way osPrintNumber takes A and B, and it never returns to answer anything. A is free precisely because a return would have put it back - and Q is the ALU's output, so a small number costs four instructions there against one in A. The shell does not print it. A program that failed has already said so in words and a number beside that is noise, so osLastStatus hands it back and Status is the program that shows it. That indirection is the point: the number exists for the thing that cannot read words. MARKING THE EXITS FOUND A DEFECT ON THE FIRST RUN. Type and More printed why they had failed and then fell through into the success exit, reporting that all was well. Nobody had noticed, because while the only reader was a person, the person could see both the complaint and the claim. Two smaller things. Snake sets the console to line mode and then exits with zero, and the linter flagged the second RSTA as redundant - an exit status and a console mode, equal by accident, which is the class that must never be collapsed. And the README still taught answering by writing into the frame, three months of habit that SRET replaced yesterday; that section is gone and the one describing SRET stands in its place. |
||
|
|
b4206673a6 |
The clean install disk is one the machine can start itself from
make run-cosmos handed the emulator a boot image AND a disk, so it took the direct path every time: memory placed from outside, nothing on the disk consulted about it. The whole boot chain was built and tested and then not used by the one command that runs the system. The disk is formatted with a boot area now, stage two goes into slot zero as raw bytes, and the system is an ordinary file at /System/Boot/cosmos.bin - which is what boot.cfg would choose between if there were one. There is not: stage two falls back to that name when the file is missing, and a clean install with nothing to configure is the right default. run-cosmos names no image, so the machine starts itself. The old behaviour is run-cosmos-direct, which is worth keeping and worth naming honestly: it is what a debugger does, and it is what to use when the thing being debugged is the boot chain, since it skips the boot chain. /Source gains stage1.asm and stage2.asm, so the machine can rebuild what starts it. Everything stage two includes is already in /Lib, so Asm stage2.asm on the machine produces the bytes a boot slot takes. Stage one is the exception and always will be: it is the ROM, and the one part of this that a disk cannot replace. Second slot left empty, which is what makes replacing the first survivable. |
||
|
|
f1cc2e56b2 |
The last handler that knew a frame offset
handleReadLine used DP1 for the frame and the conversion only looked for DP2, so it kept the whole dance - and the dance had become a no-op that looked like work: MVQA copied Q into A, A was written over the saved Q, and RETI restored it, which is where it started. readLine leaves the length in Q and SRET keeps Q, so the answer simply stands. No handler in CosmOS knows what an interrupt frame looks like now. The 0d02 and 0d05 offsets still in the file are structures - an SBEX header, an entry in the instruction table - and not frames. |
||
|
|
61a80ae13d |
Put the manual's sections back in opcode order
The user's edit. Moving branches to 0x60 and subroutines to 0x70 left their sections sitting where they used to be numerically, between the ALU and the registers, so the manual read in an order the opcode map no longer did. The list has always been arranged by opcode sequence rather than by importance or by how often a thing is used - arbitrary, and now canon. |
||
|
|
c8c9f0b363 |
SRET: a handler answers the way a subroutine does
CALL saves A, B and Data Pointers 0 to 2 and nothing else, which is exactly why Q and DP3 are how a subroutine hands something back. An interrupt saves all of it, so a service with an answer had to reach into its own frame and un-save two fields by hand: MVSD.2 DPUP.2 0d02 ; the saved Q, by an offset it had to know STA.2 RETI Thirty places in CosmOS did that. Every one knew the frame's layout by heart, and all thirty would have gone quietly wrong the day the frame gained a field - the same duplicated fact this project keeps being bitten by, except duplicated into thirty places AND into the CPU. SRET is 0x76, in the seat the block split left for it. It is RETI's frame with RET's rule applied: A, B and DP0 to DP2 come back, the saved Q and DP3 are dropped, and the Interrupt Flag is restored from the frame - only that bit, so carry survives a service the way it survives a call, and there is one rule rather than two. RETI stays exactly as it was: a hardware handler has nothing to say and must leave no trace. CosmOS is 10,969 bytes against 11,122, and no handler knows a frame offset. TWO MISTAKES WORTH RECORDING, both mine, both caught by tests. The first conversion matched STA.2 with a regular expression that did not allow a trailing comment, so it ran past the end of one handler and into the next. The second understood the pattern and still got it wrong: the old frame write carried the answer from A into the saved Q slot, so simply deleting the write left Q holding whatever it happened to hold. Services that answer by calling something were fine - Q already had it - and services that set A directly silently reported success for every failure. cosmosCwd is what noticed, by saying "cannot go there" about a directory that was there. Sixteen handlers move the answer into Q now. Seven MVQA went with it. They copied Q into A so the frame write could carry it; SRET puts A back, so they moved a value nobody would ever read. |
||
|
|
cd5f548736 |
Move the opcode map: nothing in 0x0X, and room for a return variant
Three blocks move and nothing else changes. Branches take 0x60, subroutines take 0x70, and the ALU moves up into the 0x10 block the two of them used to share. Order within each block is preserved exactly - this relocates them, it does not rethink them. WHAT IT BUYS IS AN EMPTY 0x00 TO 0x0F. Program Memory that was never written, or a load that stopped part way and left zeroes in its tail, used to read as a long run of ADDs: the machine carried on through them, arrived somewhere unpredictable, and whatever broke there was a long way from the byte that caused it. Now it faults where it is met: Fault: 0x00 at Program Address 0x0004 is not an instruction. That is the address of the byte after the last real instruction, which is the difference between a diagnosis and a search. Reserving the whole nibble rather than just 0x00 means a run into blank memory faults wherever it starts rather than only when it lands on the right byte. runOffTest records it, and the block is left empty for whatever turns out to want it. The other half is room: branches and subroutines had filled 0x10 to 0x1F between them, so a service return that keeps Q and DP3 had nowhere to sit next to its family. It has 0x76 waiting now. Five places wrote an opcode down that the scripted remap did not reach, and four of them were found by tests rather than by looking: - secondPass.c lists which opcodes take an address, and firstPass.c knows SWI by number. Missing those made XOR read as a branch. - Asm.asm knows SWI by number too, being the other assembler. Missing it made the native and host assemblers disagree byte for byte, which is exactly the check that exists to catch a thing known in two places. - loaderTest.asm carries a hand written payload, and its RETI was 0x19. To the assembler those are numbers and to the program they are data, so nothing but running it could notice. It says so in a comment now. - The Assembler Manual prints the bytes hello.asm assembles to, and two of them were branches. The monitor's recorded disassembly moved by exactly the bytes it should: 18 became 72 wherever SWI appears, with SETD and INIB untouched and every disassembled line still reading the same. |
||
|
|
ce2a2cd7e6 |
Settle is a program, and a machine with no fallback still starts
The boot state opened a loop that could not be closed from inside: the machine said "settle it to try again" and gave you no way to do so. Settle closes it, in 349 bytes. A PROGRAM RATHER THAN A SHELL WORD. The shell is for the things that cannot be done without it, and this is not one - it reaches the system through SWI like anything else, which means it can be replaced, left off a disk, or called by whatever comes to call programs in turn. That last one is the point: a shell word is not callable by anything. Two services for it. osBootState answers in Q, and a machine with no disk answers settled, because there is nothing there to be unsettled about. osBootSettle puts it back. SETTLING IS THE ONLY WRITE A PROGRAM GETS - marking a start as trying or fallen back is the loader's business, and a service that let a program claim either would let it lie about something the loader has no way to check. And a hole the tests walked into, which was mine rather than theirs. With no fallback configured, a failed start left the machine unable to start at all: the mark said do not use the system, and there was nothing else to use. That turns "the last start failed" into "no start is permitted", which is worse than the problem the mark was added to solve. With nothing to fall back to it now tries the configuration again and says so - a failure that was passing recovers, and one that is not leaves the machine exactly where it would have been without any of this, which is the most that can be promised when there is only one thing to start. docs.sh caught both new services having no row in the services table before anything else did. |