fd962f0084d136b4264319588a7c4b1cd89db093
79
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2b5506ee70 |
Stop the prompt writing off the front of its own buffer
The prompt is the working directory's path, worked out each time by walking the chain of parents up to the root. The names arrive deepest first, so they are written backwards from the end of a 127 byte buffer - and nothing bounded that walk. Nothing bounds the depth either. A path given to one operation is capped at 95 characters and a 22 character name, but "mkdir a" and "cd a" are each far inside that and can be repeated forever. Six directories of 22 characters is 132 characters of path, and at that point the walk wrote down past the front of CwdText and into what the assembler had laid out below it: the shell's own command names. ExitName sits five bytes under, so the word "exit" went first and the shell stopped recognising the command for leaving. Measured, not deduced: fine at five levels, gone at six. The walk now counts the room it has left, byte by byte, and stops. What is already written is the DEEP end of the path, which is the end worth showing, so it is cut at the front and three dots say so - out of three bytes held back from the count, so there is always somewhere to put them. Twenty levels deep the prompt shows the last five and every command still works. cosmosDeep records that, and records it by running help, cd and exit from down there rather than by looking at the prompt: a wrong prompt is cosmetic, and this was writing into other variables. It fails with the bound removed. The tree is built by SplitDisk because a path that long cannot be given to mkdir in one piece - which is the same fact that makes the depth unbounded. The three path limits are written down in the README now, including which one actually binds. The other two do not: the longest path on a full install is 21 characters. |
||
|
|
ce8fb721fe |
Say a temporary is one in the entry, not in its name
Saving something that already exists writes a temporary, deletes the original and gives the temporary its name, so that nothing is lost if the writing fails. The temporary was told apart from a real file by being called sbfs.part or sbfs.out - and those are legal names. Starting a save deleted whatever answered to one as stale scratch, so saving anything at all in a directory destroyed your own file of that name there, silently. Flag bit 0x04 now says it. The property is not in the contents - the same bytes become the finished file the instant the rename lands - so it belongs in the entry, which is the thing the commit changes. sbfsCreateTempAt is the door temporaries come in by, the commit writes the flags flat along with the name, and cleanup wipes what it finds only if the entry says it is ours. Anything else stops the save instead. The bit is also the recovery. Both listings show an unfinished write rather than sizing it, because the size in the entry is the room that was asked for and not what was written: "<unfinished>" from dir, and a line from SplitDisk saying the blocks are held and a rename brings the data back. That was the gap in what the last commit documented - the data survived a crash and nothing would show you where it was. Four new agreement checks, three of which fail with the guards removed. The fourth needed rebuilding first: both tests started on one disk, and the first save ate the sbfs.part that was the second test's SOURCE, so the copy failed for want of a file, never opened a stream, and passed while reporting on nothing. A disk each. The fifth check forges the wreckage by setting the flag on a finished file, since nothing here can crash a save half way through. No version bump: a committed file never carries the bit, so a disk this writes is byte for byte the disk the old code wrote, which the whole-image comparisons confirm. Only the wreckage differs, and older code reads that as an ordinary file - which is what it did before. |
||
|
|
9e2aa0122e |
Say what a safe save actually promises, and where it stops
Both sbfsSaveFile and sbfsStreamDone write a temporary, delete the original and rename the temporary onto its name, and the README explained why that order and not the obvious one. It never said what the resulting guarantee is, which invited the stronger reading: "safe save" sounds like it survives anything. It survives everything that goes wrong while it is running - no run long enough, no free blocks, a refused write, a name that turns out to be a directory, a writer that gives up - and in all of those the original is untouched. It does not survive the machine stopping, because the commit is two block writes with a gap between them: stop there and the old file is gone and the new one is still called sbfs.part. The bytes are all present and one rename by hand recovers them, but nothing does that on its own. Written down in the README beside the ordering it qualifies, and at both commit points in sbfs.asm, where a reader is when the question occurs to them. Closing the gap wants a journal or a second directory, which is a lot of disk for two writes; a boot-time consistency check is the cheaper answer if it ever matters, and it would want temporaries flagged in the entry rather than recognised by name. |
||
|
|
aa7bdc6acd |
Check the memory map against itself, not only against the code
The CosmOS README's Data row gave the system 0x0000-0x3FFF and a loaded application 0x2000 and above: two columns of one row that cannot both be true. Program was doubled to 0x3FFF when CosmOS outgrew its first map and that number was copied into the Data row as well, where the answer is 0x1FFF. docs.sh measured both segments against the CosmOS column and passed the table anyway, because it never read the column beside it. A number checked against the code and not against the number next to it is still unchecked, so it now reads both and compares them - and compares two further copies of the same fact that had gone stale on their own: the minimal application in the README, still based where applications lived before the doubling, and the map cosmos.asm opens with, which somebody reading the system reads before they read the README. Each of the three checks was confirmed by breaking the fact and watching it fail; the first reproduces exactly the text this commit removes. While in that header, the command list said five commands and CosmOS has eleven and a search path besides, and "dump is next" outlived the monitor. |
||
|
|
db3d349da8 |
Read the next block while the program is busy with this one
A file is read front to back, so when something asks for a block, the one after it is almost certainly wanted next. sbfsReadOne asks the disk for it straight away and hands back the block that was wanted - so the transfer happens while the caller is busy, and the waiting is mostly over by the time it comes back. Printing a fourteen kilobyte file: 1,064,498 cycles at two thousand a block becomes 976,882, and 1,576,562 at ten thousand becomes 1,032,889. The second figure barely moves between those - seven per cent from an instant disk to a slow one, where without it the same change costs sixty eight. A machine that reads ahead stops caring very much how fast its disk is. NOT FOR DIRECTORY SEARCHES, and that was tried first and thrown away. A scan stops the moment it matches, so the block it would read ahead is one nobody will ever look at - a transfer to fetch and another wait to throw away. It was nineteen per cent SLOWER on a lookup at ten thousand cycles a block. Reading ahead is a bet that the next block is wanted, and a search is exactly the case that hopes it is not. The scan loop is untouched. Three per cent is what it costs when the disk is instant, which is the bookkeeping with nothing to hide behind it, and the default. The disk has one buffer, so the only way to know what is in it is to remember what was last asked for. Every read records that; a write clears it, because a write fills the buffer from memory and no read asked for what is in it. Getting that wrong would blit whatever happened to be there and call it the block somebody wanted. The read ahead is bounded by the file's own length, so reading the last block does not fetch whatever follows the file on the disk - which belongs to somebody else and would be paid for twice, once to fetch and once to discard. It cost one bug, and an instructive one: the next index was worked out by stepping SbfsIndex, which is the CALLER'S and is still wanted after the return - handleFileBlock compares it against the file's length to see whether this was the short last block. Every block reported the wrong number of bytes, and the output of Type skipped five lines in the middle. It has a place of its own now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
d4cba36c5e |
Devices that take time, and a filesystem that waits for one
The disk's status has always had a bit meaning "still going", and the header beside it has always said to honour it. Nothing did, because nothing could: the host finished the transfer inside the instruction that asked for it, so the bit could never be seen up and asking about it was asking about something that cannot happen. --disk-cycles gives it a latency. The command is still checked at once, because a refusal is not work - a block that is not there fails before any head moves - but the transfer is remembered and done when the machine has run that far. Until then the buffer holds the block BEFORE this one. That last part is the point. A program that does not wait gets the wrong bytes rather than an error, which is the failure the bit exists to prevent and the one that would never have shown up. With a latency of two thousand, CosmOS could not even mount: sbfsMount reads block zero and looks straight at the buffer. deviceTick is the general shape rather than a disk feature. Called once per instruction with the machine's clock, it lets anything whose moment has come finish - which is what a display that refreshes, or a port that waits on the host, would want in exactly the same way. The filesystem watches the bit now, in one small routine reached with RCAL. That is not decoration: what it hands back is the settled status in A, and CALL puts A back the way it found it, so an ordinary call cannot carry the one thing this exists to carry. Two bytes of Stack rather than ten, in a routine that runs on every block the machine ever touches - the first place in the system where the new call is the right one rather than merely a cheaper one. The manifest takes a @N after a disk, the way it already takes :ro, so a test can ask for a slow one. cosmosSlowDisk lists a directory at two thousand cycles a block and gets the same listing as everything else, which is the whole assertion: a filesystem that did not wait would print nonsense rather than fail. Zero is the default and every other test runs at it. What waiting costs, on a directory heavy run: 229k cycles at zero, 275k at five hundred, 415k at two thousand, 1.16M at ten thousand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
a7d3e09d94 |
Refuse a streamed file that commits more than it reserved
osFileStart sets an extent aside and osFileWrite refuses a block index outside it, so writing off the end was already barred. Committing a larger size was not, and reaches the same neighbour by simply claiming it: a directory entry is the only record of what a file owns, so an entry claiming a block it was never given owns it, and so does whatever owned it before. Both files then look perfectly well formed. The free count went backwards past zero on the same path. Found by ChatGPT's review of the streaming work, in NOTES.md. I had bounded the index because writing off the end was the obvious way to reach a neighbour, and had not noticed that the other end of the same reservation was open. THE SIZE IS COMPARED, NOT THE ROOM IT TAKES UP. One block and a tail occupies exactly what two whole blocks occupy, so bounding the blocks alone would let a file reserve the first, commit the second, claim no block it was not given, and still report two hundred and forty six bytes that were never written to it - whatever the disk had there before. Checked before anything is touched, which is why the temporary is found twice. The old file is deleted a few lines down and a refusal after that point would have destroyed the thing it was protecting. AND IT CAUGHT A REAL ONE IMMEDIATELY. The assembler reserves the file plus room for its vectors, and asked for four bytes per vector DECLARED - which looks like a safe bound and is not, because a device is declared during the SECOND pass, in the line that implements it. A program with a device installs a vector that was not counted when the room was measured. CosmOS reserved 14,163 bytes and committed 14,167, writing four bytes past what it had been given on every build since S2. It landed inside the last block it owned, and would not have if the boundary had fallen four bytes earlier. It reserves against the vector table's LIMIT now, which cannot go stale whenever things are counted. Claim.asm is the program that tries it: reserve one block and a tail of ten, write them, then tell osFileDone the file came to two whole blocks. The refusal and the honest commit that follows are both recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
af0360128b |
Sixty four instructions becomes seventy
The six settled back on the twenty fourth, built now. RCAL and RRET are a call that puts nothing back. CALL restores A, B and Data Pointers 0 through 2, which costs ten bytes of Stack and is why a subroutine here can only hand anything back through Q, DP3 or memory. RCAL costs two and restores nothing, which is what a short leaf routine wants and is unsafe in exactly the way the name says. They are a pair because the frames are different sizes: returning from one through the other walks the Stack to somewhere that was never a return address. That was the user's correction to the original proposal, which had a raw call and no raw return. DPUA and DPDA offset a Data Pointer by A; DPUW and DPDW by A and B together, most significant first. DPUP and DPDN take a byte written into the program, so moving a pointer by something just worked out meant storing it and loading it back. Down as well as up on symmetry grounds, which was also the user's call - the argument against it came from counting uses in a corpus written under the constraint. The opcodes sit where they belong: 0x16 and 0x1E immediately below CALL and RET, and 0x4E through 0x51 at the end of the Data Pointer family. All six fit shapes that already existed, so instructiontable.py needed only set membership and both machine side copies of the table regenerated from it unchanged. Checked at every level it exists at: the emulator runs them, the host assembler encodes them, the monitor disassembles all six with the right lengths, and the assembler that runs on the machine builds a program using them byte for byte identically to the host - and that program runs. The recorded test measures what the two calls COST as well as what they put back, because an RCAL that quietly did what CALL does would still return to the right place. It does not survive that: returned through RRET, it hangs. docs.sh can read a two word number now. The count of instructions taking a Data Pointer went past twenty, and the pattern only allowed one word, so the check would have reported that the manual had stopped saying it rather than that the number was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
fb7b224bbb |
S2: the assembler writes the file as it makes it
The output image is gone. It was eighteen kilobytes and it is now one block of window, because the file was always produced in order and only ever needed to be written that way. Everything works in FILE OFFSETS now. A cursor is a two byte number counting from the front of the file, and since a block is two hundred and fifty six bytes, the block it lands in is the offset's high byte and the place within that block is its low one - so there is no division anywhere, and ImgWalk, ProgPut and DataPut needed no change but where they start. ONE WINDOW RATHER THAN THREE. The plan said three: one per segment, and a third for the block where the program ends and the data begins, which belongs to both. Fetching a block back instead makes all of that one case. The header is patched after every byte is out, the boundary block is written by both cursors, and both are simply revisits - a revisit is what fetching handles. osFileFetch is the service that allows it, and is the read side of the write. A run of bytes in one segment costs nothing extra; a switch between segments costs two block operations, and a source file has a few dozen switches and several thousand bytes. Two bugs, both a pointer meaning two things: putAt took the cursor to advance in DP2 and then wanted DP2 for the window's address. A call puts DP2 back the way it was AT THE CALL, so the step at the end moved whatever the last call had left there - the window walked off across memory while the cursor stood still. It goes in memory now, like the block did in S1, and for the same reason. The size the file is created at could not be right. How many vectors are actually installed is not known until the second pass has resolved their handlers, and by then the file must already exist to be written into - so Keys, which brings one vector, came out four bytes short. Teaching the first pass to count them meant teaching it about devices, and about a Boot line in a loadable program not being installed at all, which is two ways to disagree with the second pass about what a file contains. So osFileDone is told the size instead. A writer asks for as much as the file could possibly come to - the whole of it plus four bytes for every vector DECLARED, which no file can exceed - and says what it really came to at the end. The blocks it did not use go back to the free count. Asking for too much costs a moment; asking for too little writes off the end of a file. That is a better service for it, not a workaround. A writer that cannot know its size until the last byte is the ordinary case, and it is exactly the case this whole rung exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
9f7dffdeca |
S1: the write side learns to stream
osFileStart, osFileWrite and osFileDone are the mirror of osFileInfo and osFileBlock. A program can now write a file it never holds: Pour writes twelve blocks and a tail while keeping 256 bytes of it at a time, and the host tool reads all 3,112 bytes back with every block where it was put. ONE WRITE IS OPEN AT A TIME AND COSMOS HOLDS IT. Reading needs no state - a name and an index are the whole question - but writing safely does, because the new file has to exist before the old one is thrown away and something has to remember which temporary belongs to which name. Keeping that here means the careful order is written once instead of in every program that streams. Nothing already on the disk is touched until osFileDone, so a disk without room says so while the old file is still there. That is stronger than osFileSave can manage, where the size is only known once the caller has every byte in hand. osFileSave stays: Edit and Files hand over whole documents and have no reason to learn any of this. osFileWrite refuses an index past the end of the file, and that refusal is not politeness. Files are contiguous, so block nine of a three block file is a real block belonging to something else, and writing it would put one file's bytes inside another with nothing anywhere saying so. Checked both ways: the tail block is allowed and the one past it is not. Three bugs, all of them the same shape - a register or pointer used for two things at once: DP3 carried the block count in and was popped high byte first, which is the wrong way round from every reader in the system and made the count two hundred and fifty six times too big. sbfsStreamStart took the name in DP0 and then wanted DP0 for something else before it had read it, so it walked whatever it last pointed at and reported that it could find no room. sbfsStreamWrite kept the caller's block in DP3 across a find - DP3 being the pointer a return does not put back, which is exactly why the find uses it too. What went to the disk was whatever the scan last looked at. It goes in memory now, and the file is correct because every block says which block it is; a check on the length alone would have passed all three of these. Writing no longer finds the file for each block either. Nothing moves a file once it is made, so where it starts is settled when the temporary is created. That was not even slow - a scan stops the moment it matches - but it was a walk of the directory per block for an answer that cannot change, and it is 28 per cent of the cost of writing forty blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
06bdbf7728 |
D5: move in, and give the assembler somewhere to look
The demo disk is three directories instead of thirty nine names in one list with cosmos.asm sitting between fileStream.asm and sbfs.asm: /Apps what you run /Source what you name to the assembler /Lib what those include The split is by ROLE rather than by which directory the host keeps a file in. Everything in /Lib is named by an #Include somewhere and by nothing else, which is what makes it a library rather than a source. THAT LAYOUT WAS NOT POSSIBLE UNTIL NOW, and finding out why is what this rung actually cost. An include on the machine was a bare name resolved where you stood, so every source that calls a service had to sit in the same directory as services.asm - which is every source worth having. The first arrangement of this disk put the examples in a directory of their own and none of them would assemble. So the native assembler has a search path: beside you, then /Lib. The same rule the shell already uses for a program it does not recognise, applied to the thing that reads source, and the same reasoning for it being two fixed places rather than a list - a list needs somewhere to live between one boot and the next, and there is no such place yet. It also brings the native assembler nearer the host one, which has searched -I directories since before there was a machine to run this on. The reader's per-file state grew from 293 bytes to 301, because the name it keeps is a path now and every block of a file is asked for by it. Six of those would no longer fit the room set aside, so the include list moved up a page. Both numbers are written down in two places on purpose and both were changed. dir said cosmos.asm was 17,460 bytes. It is 82,996. The size came out of the block count's LOW BYTE shifted up and the tail beneath it, which is sixteen bits, so anything from 256 blocks upward came back as itself less 65,536 - a plausible number, and wrong. Files that big say their size in blocks now. Printing the true figure wants decimal printing twenty four bits wide, which is a page of console.asm to say something nobody reads more precisely than "big". The Assembler Manual's line about SBFS being flat was the last thing in the repository still claiming it, and docs.sh now looks for that phrase and three like it in all four documents. Not a section that is wrong - one clause inside a paragraph that is otherwise right, which is the shape this kind of staleness takes. The duplicate puts are gone with the wildcard that caused them, so building the disk is quiet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
da91a36d92 |
D4: the machine makes directories too
mkdir and rmdir are the machine's own now, and a file goes where its path says rather than always in the root. A disk can be organised without the host tool touching it. Everything below the surface works in terms of a directory and a name rather than a path. sbfsWalkParent splits the last name off, walks the rest, and hands back the two - and the separator stays on the end of the head, which is what makes one rule cover every kind of path: "/x" leaves "/", which is the root; "x" leaves nothing, which is where the machine already is; and "A/x" leaves "A/", which is neither and needs no special case to say so. Saving works in those two as well, and had to. The careful order a save uses - make a temporary, write it, delete the original, rename the temporary - only works if the temporary is made in the SAME directory as the file, because the rename at the end changes a name and does not move anything. Renaming to a path naming a different directory is refused for that reason, rather than quietly being a lie the disk goes along with. Three things this cost, all found by running it: mkdir Apps/Deep made /Apps/Apps. The leaf was worked out into SbfsWanted and then the head was walked - and walking goes through sbfsPathNext, which puts every name it meets into SbfsWanted on the way past. The head's last name landed exactly where the leaf was. It has somewhere of its own now. rmdir took a directory with something still in it, which is the one failure the whole design is arranged to prevent. Looking for children clobbered DP2 and rebuilt it from the buffer and the entry count with the subtraction the wrong way round, so the pointer walked off the end of the block and found nothing. The comparison goes through a CALL now, like the two beside it, and DP2 comes back on the entry because a RET puts it there. SplitDisk's "in use but not reachable from the root" line is what caught it. Refusing a name longer than twenty two used to read the twenty third character of a shorter one, which is somebody else's string. It is measured now. Tests/agree.sh is new and is the gate this rung was for: the same disk built twice, once with SplitDisk and once with CosmOS, compared byte for byte. The two share no code and only a written specification, and every field one writes and the other only reads is checked there and nowhere else - which entry a thing lands in, which block, what a directory's unused fields hold, the version, the free count. It caught a wrong parent immediately when that was broken on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
36ce9f6ccf |
D3: the machine knows where it is
cd moves it, dir lists the directory it is in, and the prompt says which one - but only when that is not the root, so a machine nobody has moved about on looks exactly as it always did and every recorded test that never says "cd" keeps its recorded prompt. A path beginning with a separator is measured from the root and anything else from where the machine is, so a bare name means a file in the current directory. NO PROGRAM HAD TO BE TOLD: the working directory lives in sbfs.asm beside the thing that resolves paths, because it is what a relative path MEANS. Keeping it in the shell would have meant either handing it down on every call or pasting it onto the front of every name, and the second of those is how a name that is already absolute gets ruined. Nothing stores the path. The working directory is an entry index and two bytes, and the text on the prompt is built each time by walking the chain of parents upward, writing names from the end of a buffer towards the front - which is the order they arrive in, and saves reversing them afterwards. sbfsFind splits into a walk and a check. "cd /" and "cd .." both end at the root quite legitimately, and had no way to say so through a routine whose only word for the root was "missing". Typing a program's name now tries two places in order: where you are, then /Apps. The first makes a program you are working on the one that runs; the second lets Snake work from anywhere. A word already beginning with a separator has said where to look, so only that place is tried. osChangeDir exists so that "a program may move about, and the shell puts the working directory back" is a thing that can happen rather than a promise about nothing. Both halves of that were unfalsifiable without it: with no way for a program to move, removing the restore changed no test. Wander is the program that moves - it goes where it is told and reads a file there by a bare name - and with it on the disk, removing the restore fails. The remembered file is dropped whenever what a relative path means changes: a cd, a program calling osChangeDir, a program exiting. Removing all of them fails the test and removing any one of them does not, because today every path into that cache belongs to a program that exits. It is kept in all three because the cost is a call and the failure is a file's blocks being handed out under another file's name. The cwd fixture holds two files called notes.txt saying different things, and a Say.sbx in /A that is really hello. Two copies of one program, or two copies of one file, would have passed with the whole of this deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
36a1b07b5b |
D2: the machine walks a path
sbfsFind takes a path where it used to take a name: names with '/' between them, walked from the root, with '.' and '..'. Each name is looked for among the entries whose parent is where the walk has got to. A bare name is a path of one name, so everything written before directories existed still works and still costs one walk of the directory. sbfsMount takes either version. On a version one disk every entry has zeroes where a parent goes and the walk starts at zero, so the comparison always agrees - which is how a flat disk reads correctly here with nothing done to it. PROGRAMS DID NOT HAVE TO BE TAUGHT ANY OF THIS. Resolution sits inside sbfsFind, below the services, so every osFile call keeps its signature and a path is simply a longer name. Type, More, Edit and the assembler gained subdirectories without a line changing in any of them. Four things this turned up, none of which was the path walk: load copied the path into a buffer sized for a NAME, so anything over 22 characters was cut short - and cut short into a path that often still resolved. "/Apps/Deep/../../Apps/Say.sbx" became "/Apps/Deep/../../Apps/" and reported that the program was a directory. That is the whole of what looked like a bug in '..', and it cost most of the time here. load on a directory SUCCEEDED. A directory has no blocks, so reading it reads nothing and leaves the staging area holding whatever was staged last - which, if that was a program, still says SBEX and still has a working entry address. It handed back the program before it. Refused outright now. delete and rename on a directory are refused, and save refuses one up front rather than failing at the rename and leaving a temporary behind. Deleting a directory frees an entry index, and a parent IS an index, so the next file created would take it and inherit the children. create writes the parent rather than leaving it zero by luck. It would be zero - delete wipes all thirty two bytes and a fresh entry never had any - but that is a fact about two other routines, and a file appearing inside a directory it was never put in is not a failure anybody would think to look for. dir marks directories and counts them apart from files, because at this point it was calling them files of no bytes. Two hazards written down in the design note turned out not to be real, and both were checked rather than argued about: The lookup cache holding 22 bytes of a longer path cannot hand back the wrong file - textSame wants both strings to end in the same place, so a cut down entry misses. It can never HIT either, though, so every path longer than a name went to the disk every time; it holds a whole path now. The allocator stepping over directories changes nothing any test can see. A directory has no start as well as no blocks, so its bounds are nought to nought and no candidate begins before it ends. The four instructions stay, with a comment saying they are not load bearing today and why they are there anyway. makedisks.sh resolves its build path before it cds. Given a relative one it carried on and quietly built disks missing some of their files, which is how the tree fixture lost a file and sent me looking for a bug in '..'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
2b0aeeefd4 |
Start a program by typing its name
A word the shell has no command for is now looked for on the disk as "<name>.sbx", and if it is there it is loaded and started exactly as load and run would do it. Whatever followed the word reaches the program through osArgument by the same route as whatever follows run, so "Say hello there" and "Type notes.txt" work without either program knowing how it was started. load and run are unchanged and both stay. load is how the monitor puts an arbitrary file in front of itself, which typing a name deliberately cannot do: the extension is added rather than assumed, so "notes.txt" looks for notes.txt.sbx and a text file is unreachable by name whatever is inside it. Three things this had to get right: The built-ins are tried first and always win. The search hangs off the end of the dispatch chain, so a file called dir.sbx cannot become dir, and the commands worth trusting when the disk is what you are doubting stay trustworthy. The invoke disk carries a working dir.sbx so that this is checked rather than asserted. A file that is found but is broken says so. "not a program" and "I do not know" are different answers, and giving the second about a file sitting on the disk would send somebody looking in the wrong place. loadProgram therefore hands back a status as well as a message, since only "no file of that name" can fairly be reported as anything other than a fault. doLoad became that subroutine rather than being copied. It ends in RET instead of a jump to the prompt, and each way of failing sets its number and its text together so a new one cannot leave half of the answer behind. cosmosBreak moves because Break prints the pointers it was handed and those are the shell's leftovers, which a CALL now puts back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
dbe58db660 |
Add Type and More, and the file stream they are built on
Two applications that read a file too big for Data Memory: Type prints one, More pages it. Both sit on fileStream.asm, which wraps osFileInfo and osFileBlock into open-and-next so an application walks a file's blocks without repeating the service calls. The disk fixture is deliberately awkward: readable.txt crosses several blocks and carries no zero byte to be mistaken for an end marker, and empty.txt says that zero blocks is a valid file rather than an error. These three files were written by ChatGPT, as their headers record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
4fd8bf7b3f |
Step a Data Pointer with INCD and DECD, not DPUP and DPDN by one
DPUP takes an immediate, so an offset of one is legal and does exactly the right thing. It is also three bytes where INCD is two, and reads as "offset the pointer up by one" where INCD reads as "step the pointer". 56 of them across 15 files: the system, the assembler, the editor, and eight test programs. CosmOS is 9,564 bytes to 9,537, the native assembler 11,648 to 11,635, and every program in the repository together 49 bytes lighter. The worst offender was numbers.asm, written this week, where every sixteen bit helper reaches the low byte and comes back the long way round. It is the file every other part of the assembler includes, so it is the first thing anybody reads when they go looking - and it was teaching them the long way. Pattern matched off sbfs.asm rather than off the instruction table I had just embedded in two programs. THIS IS NOT TWO WAYS TO DO ONE THING. DPUP takes an arbitrary number, so one is inevitably among them; INCD earns its place by making the common case a byte cheaper. The overlap is structural and the choice is a usage question, which is a linter's job rather than an ISA's - "DPUP.n 0d01: INCD.n does this in a byte less" is a mechanical rule with no judgement in it. Nothing needed re-recording, which was not a foregone conclusion: cosmosBreak prints the system addresses the registers happened to hold, and they did not move. Both assemblers still produce identical bytes and CosmOS still builds itself to a fixed point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
3d2ab34229 |
Streaming: read a file bigger than the machine's memory
osFileRead hands over a whole file, which settles anything under 64K and settles nothing above it. CosmOS's own source is above it - the sources together are 104K against 64K of Data Memory - so a machine that is going to assemble itself needs another way to ask. osFileInfo (0d26) says how many blocks a file occupies. osFileBlock (0d27) hands over one of them and says how many of its bytes belong to the file. Between them a program reads a file of any size through a buffer of 256. Blocks rather than bytes from osFileInfo is forced, not chosen: a file on a sixteen megabyte disk is up to twenty four bits long and a pointer holds sixteen. osFileBlock's count answers in DP3 for the same kind of reason - a whole block is 256 bytes, which does not fit in a register, and a count that reported it as zero would make every reader special-case the end. Nothing is kept open. Every call names the file, so there is no handle to leak and nothing left behind by a program that stops halfway. Taken at its word that means searching the directory once per block, so the system remembers where the last file it was asked about lives; every path that can change what a name means calls fileForget, including the shell's own delete and rename, which do not go through the services. Correctness never depends on the cache - a cache thrown away is indistinguishable from one never filled. Measured on a 329 block file: 7% of the run saved when the file is the first directory entry, 11% when it is the sixteenth. These two say WHY when the answer is no, which the others do not. Elsewhere the only useful response to a failure is to give up, so one value suffices. These are asked questions, and running off the end is how a reader learns it has finished, so it gets an answer of its own: 1 no disk, 2 no such file, 3 past the end, 4 the disk refused. Apps/Stream.asm reads an 84,000 byte file through 256 bytes. The check that matters is the second one: a small file read BOTH ways - whole with osFileRead and streamed - with the two checksums compared, so streaming is measured against the path already known to work rather than against a number someone wrote down. The checksum is Fletcher's rather than a sum, because a sum is the same whatever order the bytes arrived in and the order is exactly what streaming has to get right. Both checksums were also confirmed against the same arithmetic run on the host. The rest of the test is the cache: two files read alternately catch a memory that missed the name changing, and a rename catches one that missed the file moving - and that one would otherwise pass, since the blocks are still there holding the same bytes. The test file is generated rather than taken from the repository. The CosmOS sources would be a truer picture and would move the recorded checksum every time a line of CosmOS was edited, putting a real difference in a crowd of meaningless ones - the same trap the cycle counts used to set. cosmosBreak's recorded output moves by two bytes in two pointers: SbfsIndex added two bytes to the filesystem's data and Break prints the system addresses the registers happened to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
3b800a69e8 |
Monitor: a line assembler
a <address>, then instructions until a line that is just a dot. The syntax is the assembler's own: a selector rides on the mnemonic as LDA.0 or LDD.0.1, and leaving one off means Data Pointer 0 exactly as it does in a source file, so nothing learned at the monitor has to be unlearned when writing a program. Case is folded, since the assembler does not care either. Numbers are hexadecimal and bare. A source file writes 0x2000 or 0d16 because it has both and must say which; a monitor has one and says so once, in the manual, rather than on every line. It reads the same table the disassembler does, searched the other way round, which is the point of it being a table rather than two lists: what a writes, d reads back, and neither can drift from the other or from the assembler both were generated from. Instruction lengths come from the shared shape table too, so the cursor cannot get out of step with what was written. THE WHOLE LINE IS UNDERSTOOD BEFORE ANYTHING IS WRITTEN. Emitting the opcode first and discovering a missing operand afterwards leaves half an instruction in memory, which the next line usually covers up and the last line of a session does not. Written that way first and fixed. What cannot be written is a label, and that is the whole difference between this and the assembler proper: a label is a promise to fill an address in later, and later is what a line at a time does not have. The recorded test now types in a complete program - a string poked into Data Memory, instructions assembled into Program Memory, and the result run - and includes a lower case mnemonic, both selector forms, an instruction that does not exist and one missing its value, so the refusals sit beside the successes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c23adb2836 |
Break: name the status register properly and show the Stack Pointer
The dump labelled the status register "S", which reads as Stack to anybody sensible - and the Stack Pointer was the one register it did not show, so there was nothing to contradict the guess. It is written "status" now, and followed by the bits that are up, because a dump that makes you look the number up is only half a dump. The Stack Pointer is not in the frame, since the frame is where the Stack Pointer is. What the program had is fourteen bytes above it, that being what entering an interrupt puts down, so it is worked out and shown. Apps/Break.asm takes its second stop inside a subroutine, so the recorded output shows the Stack Pointer at FFFF and then at FFF5: a difference of ten, which is the size of a CALL frame. That checks the value is derived rather than constant, which the previous version could not have told you. Reported by Anachronaut, who read the output and asked why a pointer was two digits long. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5fd995aa62 |
Breakpoints: SWI osBreak, and s refuses a read only bank
A breakpoint that shows every register as the program had them, waits for a key, and carries on. NOTHING IS OVERWRITTEN, and that is the design rather than a shortcut. A breakpoint poked into a running program has to replace an instruction, and putting that instruction back in order to continue is the same act as disarming the breakpoint; firing a second time would mean stepping over the restored instruction and putting the breakpoint back behind it, and this machine cannot step a single instruction. SWI is two bytes, dispatches through a vector, and its frame already holds the address after it, so RETI resumes at the next instruction with nothing to restore and nothing to re-arm. It fires every time it is reached. The price is that a breakpoint is part of the program: a build with them in has different addresses from a build without. That is the bargain every machine with a break instruction makes. Every value shown comes out of the frame rather than the registers, because by the time the handler runs the registers are the handler's. Apps/Break.asm stops twice so that the second stop is checked as well as the first. Also here, found by the test that came with it: the monitor's s wrote into whichever bank was selected, and bank 2 is the controller's own table, published read only. Writing to it was refused, and a refusal nobody catches stops the machine - so selecting the bank table to look at it and then typing s killed the session. bankPresent now keeps the whole flags byte and s declines. The recorded output of cosmosMonitor had contained that crash, having been blessed without being read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0b6d2be43f |
CosmOS: a service interface for the disk and console, and the monitor in the shell
Two changes that arrived together because both live in cosmos.asm. THE SERVICES. A loaded program that wanted a file had to include the whole filesystem, carrying two and a half kilobytes of a private copy of code the system already had running, and then mount a disk that was already mounted. Five services are added at pinned numbers 20 to 24: osFileRead, osFileSave, osFileDelete, osFileRename and osPrintNumber. The sizes fit the registers exactly in both directions. A file that can be read into Data Memory is under 64K by definition, so its length is sixteen bits: coming back it is DP3, going out it is A and B together, and neither direction needs a record in memory whose shape both sides must agree on. There is deliberately no service to mount a disk. The system mounts one before its first prompt, and a program mounting it again was only ever a consequence of owning a second copy of the library, so that call disappears rather than moving. Apps/Files.asm writes, reads, renames and deletes a file in 645 bytes and includes nothing but the service names. THE MONITOR. Previously an application, now part of the shell, because an application occupies the one region a loaded application is given: a monitor that was an application could never examine another one, since loading the thing to be inspected would replace the thing doing the inspecting. "monitor" turns it on and the prompt becomes "*". It is a mode rather than a sub-prompt, and it persists: because the mode is a variable the prompt reads rather than a second loop, and every path back to the prompt goes through one place including osExit, a program started with "g" that gives the machine back arrives at the monitor prompt it was started from. Examining a program and running it therefore do not interrupt each other. "exit" leaves whatever you are in. It supersedes dump, and adds disassembly, writing bytes, and jumping to an address. Its instruction table is generated from the assembler's own list by Tests/instructiontable.py rather than typed again, and Tests/docs.sh checks both that the system's copy matches the generator and that the lengths that table implies are the ones the manual's Bytes column prints. A disassembler that disagreed about a length would not print one line wrong, it would lose its place and print everything after it wrong. Also here: b refuses a bank that is not registered, since asking the controller for one is refused and a refusal nobody catches stops the machine; g records the Stack the way run does, without which a program returning through osExit restored whatever the last run had left; and make cosmos-disk now depends on the system as well as the image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
301716e869 |
sbfs: give sbfsMatchEntry a counter of its own
sbfsFind counts a block's eight entries in SbfsCount and calls sbfsMatchEntry for each one, and sbfsMatchEntry used the same variable for its own twenty two byte comparison. A name differing at its first byte returned without decrementing, leaving 22 in the entry counter, so the search then walked twenty two entries through a buffer holding eight: off the end of it, into whatever data happened to follow, matching against rubbish and reporting a file that is not there as found. It had always been there. What decided whether it looked like it worked was the bytes after SbfsBuffer, so it stayed hidden until unrelated data was added and changed them. It showed up as "dir" followed by any failed "load" reporting that the file could not be read rather than that it does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c4b59acc68 | Infrastructure for system services through software interrupts. | ||
|
|
e3100b4718 | Fixed assembler bug that caused crash on IR array resize. Added line editor app. | ||
|
|
1d1a14318c | Programs can now list and share vectors. | ||
|
|
9e3425d34b | Programs can now pin specific routines to specific vectors in SplitBit assembly. Added snake game. | ||
|
|
08624925fe | Interrupt on keypress mode | ||
|
|
91c9d49d1b | CosmOS pre-alpha and launchable application versions of old programs. |