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
This commit is contained in:
Anachronaut
2026-08-25 09:18:29 -04:00
co-authored by Claude Opus 5
parent 36ce9f6ccf
commit da91a36d92
13 changed files with 948 additions and 43 deletions
+42 -4
View File
@@ -23,6 +23,8 @@ for itself.
- Working Directory: `cd` moves the machine, `dir` lists where it is, and the prompt says
where that is once it is not the root. A program may move too, and the shell puts the
working directory back when the program stops.
- Making Directories: `mkdir` and `rmdir` on the machine, and files written where their
path says, so a disk can be organised without the host tool.
- Loadable Applications: Validate SBEX files, copy their Program and Data segments into
the addresses for which they were assembled, and start them at their declared entry
point.
@@ -92,6 +94,8 @@ CosmOS currently provides these built-in commands:
| `load <path>` | Read and validate an SBEX application, then place its code and data where its header requests. |
| `run [words]` | Start the loaded application and make the rest of the line available to it as an argument. |
| `cd [path]` | Go to a directory, or to the root with nothing after it. |
| `mkdir <path>` | Make a directory. |
| `rmdir <path>` | Remove one, if it is empty. |
| `<name> [words]` | Any word the shell does not recognise is looked for on the disk as `<name>.sbx`, and loaded and started if it is there. |
| `delete <file>` | Remove a file from the filesystem and release its blocks. |
| `rename <file> <to>` | Give a file a different name without moving its contents. |
@@ -194,10 +198,44 @@ path as it was typed, so `notes.txt` is the same key in two directories and noth
the entry it holds would look wrong. It is the kind of stale that gets believed rather
than noticed.
At this stage CosmOS **reads** directories and does not make them: there is no `mkdir`, and
files a program writes go in the directory you are in. Deleting or renaming a directory is
refused - deleting one would free its entry index, and since a parent is written as an
index, the next file created would take that index and inherit its children.
### Making And Removing Directories:
`mkdir` and `rmdir` are the machine's own, so a disk can be organised without the host
tool. A file a program writes goes where its path says, and a bare name means the
directory you are in.
**A directory costs one entry and no blocks at all.** Its start, block count and tail are
all zero, which is what keeps the flat array of entries the whole allocation map - with
files laid down contiguously, every block is inside some entry's range or it is not, and
an entry with no range is in nobody's way.
Making the first directory on a disk is what raises it from version one to version two,
because it is the only thing that makes the difference between them real. A disk stays
readable by anything that has never heard of a directory right up until it actually has
one.
Four things are refused, and each refusal is the reason a separate command exists:
**`rmdir` will not take a file and `delete` will not take a directory.** Neither can be
the one that removed more than was asked for.
**A directory with anything in it is refused.** This is not politeness. A parent is an
entry *index*, and a freed index is handed to the next thing created - so the children of
a directory removed from under them would turn up inside whatever took its place. Nothing
points downward, so there would be no way to find them afterwards and no way to notice.
**A name already used in that directory is refused.** Two entries with one name in one
place is a directory that cannot be searched sensibly: a search answers with whichever it
meets first, and the other becomes unreachable without ever having been deleted. The same
name in a *different* directory is fine, and is the point of the exercise.
**`rename` will not move anything.** Only the twenty two bytes of the name change and the
parent is not among them, so `rename a/x b/y` would be a lie the disk went along with.
`Tests/agree.sh` builds the same disk twice, once with SplitDisk and once with CosmOS, and
compares the images byte for byte. 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.
### Starting An Application By Name:
+84 -1
View File
@@ -142,6 +142,16 @@ promptSay:
CALL textSame
BRQ doCd
SETD.0 CommandLine
SETD.1 MkdirName
CALL textSame
BRQ doMkdir
SETD.0 CommandLine
SETD.1 RmdirName
CALL textSame
BRQ doRmdir
SETD.0 CommandLine
SETD.1 DeleteName
CALL textSame
@@ -1006,6 +1016,61 @@ cdNotDirectory:
SETD.0 NotDirectory
BRI fileComplain
; ---- mkdir and rmdir ----
;
; Two commands rather than one that works out what you meant, and delete stays for files
; only. Each of the four says exactly what it will take, so none of them can be the one
; that took away more than was asked for.
doMkdir:
SETD.0 DiskReady
LDA.0
BRA fileNoDisk
SETD.1 TextRest
LDD.0.1
LDA.0
BRA mkdirWhat
CALL sbfsMakeDir
BNQ mkdirFailed
SETD.0 MadeText
CALL printString
CALL newLine
BRI prompt
mkdirWhat:
SETD.0 MkdirWhat
BRI fileComplain
mkdirFailed:
SETD.0 MkdirNo
BRI fileComplain
doRmdir:
SETD.0 DiskReady
LDA.0
BRA fileNoDisk
SETD.1 TextRest
LDD.0.1
LDA.0
BRA rmdirWhat
; What a name means on the disk is about to change, so the remembered file goes.
CALL fileForget
CALL sbfsRemoveDir
BNQ rmdirFailed
SETD.0 RemovedText
CALL printString
CALL newLine
BRI prompt
rmdirWhat:
SETD.0 RmdirWhat
BRI fileComplain
rmdirFailed:
SETD.0 RmdirNo
BRI fileComplain
; ---- Writing out where the machine is ----
;
; Builds the working directory's path into CwdText and leaves CwdAt pointing at where it
@@ -3122,6 +3187,18 @@ DirectoryText:
"<dir>"
NotDirectory:
"that is not a directory"
MadeText:
"made"
RemovedText:
"removed"
MkdirWhat:
"mkdir what?"
RmdirWhat:
"rmdir what?"
MkdirNo:
"cannot make that: check the path, the name, and whether it is taken"
RmdirNo:
"cannot remove that: it must be a directory, and empty"
Separator:
"/"
@@ -3151,7 +3228,9 @@ load <file> read a program off the disk
run [words] start what was loaded, and tell it those words
<name> [words] look where you are and then in /Apps, and start that"
HelpCdText:
"cd [path] go to a directory, or to the root with nothing after it"
"cd [path] go to a directory, or to the root with nothing after it
mkdir <path> make a directory
rmdir <path> remove an empty one"
HelpMoreText:
"delete <file> take it off the disk
rename <file> <to> call it something else
@@ -3285,6 +3364,10 @@ RenameName:
"rename"
CdName:
"cd"
MkdirName:
"mkdir"
RmdirName:
"rmdir"
HelpName:
"help"
ExitName:
+597 -34
View File
@@ -320,6 +320,173 @@ sbfsFindMissing:
ADD
RET
; ---- Splitting a path into where and what ----
;
; DP0 points at a path. Everything but the last name is walked, so what comes back is the
; directory a thing should be MADE in and the name to make it under: SbfsAt is the
; directory and SbfsWanted holds the name, padded to twenty two the way an entry holds one.
; Q is zero if that worked.
;
; The head is copied rather than the path being cut in place. The path belongs to whoever
; called, and a routine that writes a zero byte into somebody else's string is one that has
; to put it back on every way out, including the ways out that failed.
;
; THE SEPARATOR STAYS ON THE END OF THE HEAD, and that is what makes one rule cover both
; kinds of path. "/x" leaves a head of "/", which is the root; "x" leaves an empty head,
; which is where the machine already is; and "A/x" leaves "A/", which is neither of those
; and needs no special case to say so.
sbfsWalkParent:
RSTA
SETD.1 SbfsHeadLen
STA.1
SETD.1 SbfsSpanAt
STA.1
PSHD.0
POPD.2 ; DP2 walks the path, DP0 stays on the front of it.
sbfsSplitScan:
LDA.2
BRA sbfsSplitEnd
INIB 0x2F
CCF
SUB
BNQ sbfsSplitStep
; A separator. The head runs to just past it, so the last one to be seen wins.
SETD.1 SbfsSpanAt
LDA.1
INCA
SETD.1 SbfsHeadLen
STA.1
sbfsSplitStep:
SETD.1 SbfsSpanAt
LDA.1
INCA
STA.1
BRA sbfsSplitTooLong ; Round past two hundred and fifty five: not a path.
INCD.2
BRI sbfsSplitScan
sbfsSplitEnd:
; The head, up to and including the separator that ended it.
PSHD.0
POPD.2
SETD.1 SbfsHead
SETD.0 SbfsHeadLen
LDA.0
INIB 0d95
CCF
SUB
BNC sbfsSplitTooLong ; Longer than there is room to copy it into.
SETD.0 SbfsHeadLen
LDA.0
SETD.0 SbfsSpanAt
STA.0 ; Counting it back down again.
sbfsHeadCopy:
SETD.0 SbfsSpanAt
LDA.0
BRA sbfsHeadDone
DECA
STA.0
LDA.2
STA.1
INCD.2
INCD.1
BRI sbfsHeadCopy
sbfsHeadDone:
RSTA
STA.1 ; The zero that ends the head.
; And the last name, which is whatever DP2 is now on. Measured before it is copied,
; because twenty two is all an entry holds and a longer name is refused rather than cut
; down - a name cut to twenty two characters is a different name and might well be
; taken. Measuring is the only way to know: looking at the twenty third character of a
; shorter name reads past the end of somebody else's string.
PSHD.2
POPD.0
LDA.0
BRA sbfsSplitNoName ; The path ended in a separator, so it names nothing.
RSTA
SETD.1 SbfsSpanAt
STA.1
sbfsLeafMeasure:
LDA.2
BRA sbfsLeafMeasured
SETD.1 SbfsSpanAt
LDA.1
INCA
STA.1
INIB 0d23
CCF
SUB
BRQ sbfsSplitTooLong
INCD.2
BRI sbfsLeafMeasure
sbfsLeafMeasured:
; KEPT SOMEWHERE OF ITS OWN, and this is not tidiness. Walking the head goes through
; sbfsPathNext, which puts every name it meets into SbfsWanted on its way past - so the
; last name of the head would land exactly where the leaf was and the thing would be
; created under the name of the directory it was going into. "mkdir Apps/Deep" made
; /Apps/Apps.
SETD.1 SbfsLeaf
CALL sbfsKeepName
; Now where it goes. The head is walked exactly the way any other path is.
SETD.0 SbfsHead
CALL sbfsWalk
BNQ sbfsSplitNoName
; And the leaf comes back out, now that nothing else is going to write there.
SETD.0 SbfsLeaf
SETD.1 SbfsWanted
INIA 0d22
SETD.2 SbfsCount
STA.2
sbfsLeafBack:
LDA.0
STA.1
INCD.0
INCD.1
LDA.2
DECA
STA.2
BNA sbfsLeafBack
; And it has to be somewhere things can be put. The root always is.
SETD.0 SbfsAt
LDA.0
INCD.0
LDB.0
OR
BRQ sbfsSplitGood
SETD.0 SbfsFoundFlags
LDA.0
INIB 0x02
AND
BRQ sbfsSplitNoName
sbfsSplitGood:
RSTA
RSTB
CCF
ADD
RET
sbfsSplitTooLong:
sbfsSplitNoName:
RSTA
INIB 0d1
CCF
ADD
RET
; ---- Taking a path apart ----
;
; Copies the next name out of the path into SbfsWanted, padded with zeroes to twenty two
@@ -502,6 +669,21 @@ sbfsScanFound:
ADD ; Q is zero: found.
RET
; DP2 is on an entry. Q is zero if it lives in the directory SbfsHoldAt names. The third
; of these, and the reason all three are subroutines: a RET puts DP2 back on the entry.
sbfsMatchHold:
PSHD.2
POPD.0
DPUP.0 0d28
SETD.2 SbfsHoldAt
CALL sbfsSameByte
BNQ sbfsMatchHoldDone
INCD.0
INCD.2
CALL sbfsSameByte
sbfsMatchHoldDone:
RET
; DP2 is on an entry. Q is zero if it lives in the directory the machine is in. The same
; comparison sbfsMatchParent makes, against the other of the two places a walk can be.
sbfsWalkHere:
@@ -910,6 +1092,24 @@ sbfsMatchYes:
ADD
RET
; Twenty two bytes from DP0 to DP1, which is one name exactly as an entry holds it. Not
; sbfsCopyWord's job and not sbfsKeepName's either: this one is for a name that is already
; padded and is only being put somewhere safe.
sbfsCopyName:
INIA 0d22
SETD.2 SbfsCount
STA.2
sbfsCopyNameLoop:
LDA.0
STA.1
INCD.0
INCD.1
LDA.2
DECA
STA.2
BNA sbfsCopyNameLoop
RET
; Copies the name at DP0 into DP1, twenty two bytes of it, padding with zeroes the way an
; entry is padded so that the two can be compared as they stand.
sbfsKeepName:
@@ -1261,9 +1461,13 @@ sbfsExtentDone:
; which has to be settled before it is made because nothing here can grow one afterwards.
; Q is zero if it was made, and then SbfsFileStart says where its blocks are.
sbfsCreate:
SETD.1 SbfsWanted
CALL sbfsKeepName
; Where it goes and what it is called come out of the path together. Everything below
; works from SbfsAt and SbfsWanted, so anything that already knows those two can start
; at sbfsCreateAt and skip the walking.
CALL sbfsWalkParent
BNQ sbfsCreateFailed
sbfsCreateAt:
CALL sbfsFileExtent
CALL sbfsAllocate
BNQ sbfsCreateFailed
@@ -1341,19 +1545,16 @@ sbfsCreateFill:
LDA.0
STA.1
; THE ROOT, SAID OUT LOUD. A new file goes in the root because nothing here can put one
; anywhere else yet, and the two bytes that say so are written rather than assumed to be
; zero already. They would be - a free entry has been wiped by delete or has never been
; used - but that is a fact about two other routines, and a fact kept somewhere else is
; one that can be changed without this noticing. A file appearing inside a directory it
; was never put in is not a failure anybody would think to look for.
; WHICH DIRECTORY IT IS IN, written rather than left to be zero by luck. A free entry
; has been wiped by delete or has never been used, so those two bytes would say the root
; on their own - but that is a fact about two other routines, and a fact kept somewhere
; else is one that can be changed without this noticing. A file turning up inside a
; directory it was never put in is not a failure anybody would think to look for.
PSHD.3
POPD.1
DPUP.1 0d28
RSTA
STA.1
INCD.1
STA.1
SETD.0 SbfsAt
CALL sbfsCopyWord
PSHD.3
POPD.1
@@ -1597,6 +1798,272 @@ sbfsSameByte:
XOR
RET
; ---- Making a directory ----
;
; DP0 names one. Q is zero if it was made.
;
; A directory costs ONE ENTRY AND NO BLOCKS AT ALL: its start, its block count and its tail
; all stay zero. That is what keeps the flat array of entries the whole allocation map,
; which is the thing this filesystem is built on - with files laid down contiguously, every
; block is inside some entry's range or it is not, and an entry with no range is in
; nobody's way.
sbfsMakeDir:
CALL sbfsWalkParent
BNQ sbfsMakeFailed
; Nothing of that name in there already. Two entries with one name in one directory is a
; directory that cannot be searched sensibly: a search answers with whichever it meets
; first, and the other becomes unreachable without ever having been deleted.
CALL sbfsScanFor
BRQ sbfsMakeFailed
; A scan that found nothing leaves the walk where it was, so SbfsAt still says where
; this is going. Walking the head again to be sure would put the head's last name back
; into SbfsWanted and undo the leaf.
; A free entry, found the same way creating a file finds one.
SETD.0 SbfsDirStart
SETD.1 SbfsBlock
CALL sbfsCopyWord
SETD.0 SbfsDirBlocks
INCD.0
LDA.0
SETD.1 SbfsLeft
STA.1
sbfsMakeBlock:
CALL sbfsReadBlock
BNQ sbfsMakeFailed
SETD.1 SbfsBuffer
CALL sbfsBufferOut
SETD.2 SbfsBuffer
INIA 0d8
SETD.1 SbfsCount
STA.1
sbfsMakeEntry:
LDA.2
INIB 0x01
AND
BRQ sbfsMakeFill ; This one is free.
DPUP.2 0d32
LDA.1
DECA
STA.1
BNA sbfsMakeEntry
SETD.0 SbfsBlock
CALL sbfsStepWord
SETD.1 SbfsLeft
LDA.1
DECA
STA.1
BRA sbfsMakeFailed ; The directory is full.
BRI sbfsMakeBlock
sbfsMakeFill:
PSHD.2
POPD.3
INIA 0x03
STA.3 ; In use, and a directory.
; No blocks, no start and no tail. Written out rather than left alone, because a free
; entry is not the only thing that lands here.
PSHD.3
POPD.1
INCD.1
INIB 0d5
sbfsMakeZero:
RSTA
STA.1
INCD.1
DECB
BNB sbfsMakeZero
PSHD.3
POPD.1
DPUP.1 0d28
SETD.0 SbfsAt
CALL sbfsCopyWord
PSHD.3
POPD.1
DPUP.1 0d06
SETD.0 SbfsWanted
INIA 0d22
SETD.2 SbfsCount
STA.2
sbfsMakeName:
LDA.0
STA.1
INCD.0
INCD.1
LDA.2
DECA
STA.2
BNA sbfsMakeName
SETD.1 SbfsBuffer
CALL sbfsBufferIn
CALL sbfsWriteBlock
BNQ sbfsMakeFailed
CALL sbfsRaiseVersion
RET
sbfsMakeFailed:
RSTA
INIB 0d1
CCF
ADD
RET
; The disk now has a directory on it, so it is a version two disk and has to say so.
;
; THIS IS THE ONLY THING THAT RAISES THE NUMBER, because it is the only thing that makes
; the difference between the two versions real. A disk stays readable by anything that has
; never heard of a directory right up until it actually has one.
sbfsRaiseVersion:
RSTA
SETD.0 SbfsBlock
STA.0
INCD.0
STA.0
CALL sbfsReadBlock
BNQ sbfsRaiseDone
SETD.1 SbfsBuffer
CALL sbfsBufferOut
SETD.0 SbfsBuffer
DPUP.0 0d04
INIA 0d2
STA.0
SETD.1 SbfsBuffer
CALL sbfsBufferIn
CALL sbfsWriteBlock
sbfsRaiseDone:
RSTA
RSTB
CCF
ADD
RET
; ---- Removing a directory ----
;
; DP0 names one. Q is zero if it went.
;
; ANYTHING STILL INSIDE IT IS A REFUSAL, and that is not politeness. A parent is an entry
; INDEX, and a freed index is handed straight to the next thing put on the disk - so the
; children of a directory removed from under them would turn up inside whatever took its
; place. Nothing points downward, so there would be no way to find them afterwards and no
; way to notice. Emptying it first is the only safe order there is.
sbfsRemoveDir:
CALL sbfsFind
BNQ sbfsRemoveFailed
SETD.0 SbfsFoundFlags
LDA.0
INIB 0x02
AND
BRQ sbfsRemoveFailed ; A file. Deleting is for those.
; Where it is, kept while the disk is asked whether anything lives in it - the asking
; reads other blocks and leaves the walk somewhere else entirely.
SETD.0 SbfsAt
SETD.1 SbfsHoldAt
CALL sbfsCopyWord
CALL sbfsHasChildren
BRQ sbfsRemoveFailed
; Found again, because looking for children read over the block the entry was in.
SETD.0 SbfsHoldAt
SETD.1 SbfsTarget
CALL sbfsCopyWord
SETD.0 SbfsTarget
CALL sbfsBackWord
CALL sbfsAtIndex
BNQ sbfsRemoveFailed
CALL sbfsWipeFound
RET
sbfsRemoveFailed:
RSTA
INIB 0d1
CCF
ADD
RET
; Q is zero if anything at all says its parent is SbfsHoldAt. Nothing points downward, so
; the only way to know what is in a directory is to ask everything whether it is in it.
sbfsHasChildren:
SETD.0 SbfsDirStart
SETD.1 SbfsBlock
CALL sbfsCopyWord
SETD.0 SbfsDirBlocks
INCD.0
LDA.0
SETD.1 SbfsLeft
STA.1
sbfsChildBlock:
CALL sbfsReadBlock
BRQ sbfsChildLoaded
RET ; The read failed, and Q says so - which reads as "yes", and
; refusing to remove something on a disk that will not read is the
; right way round to be wrong.
sbfsChildLoaded:
SETD.1 SbfsBuffer
CALL sbfsBufferOut
SETD.2 SbfsBuffer
INIA 0d8
SETD.1 SbfsCount
STA.1
sbfsChildEntry:
LDA.2
INIB 0x01
AND
BRQ sbfsChildNext
; Through a CALL, so that DP2 comes back on the entry without anything having to work
; out where that was. Doing the comparison in line here meant clobbering DP2 and then
; rebuilding it from the buffer and the count - which had the subtraction the wrong way
; round, walked the pointer off the end of the block, and let rmdir take a directory
; with something still in it. Exactly the failure this routine exists to prevent.
CALL sbfsMatchHold
BRQ sbfsChildYes
sbfsChildNext:
DPUP.2 0d32
SETD.1 SbfsCount
LDA.1
DECA
STA.1
BNA sbfsChildEntry
SETD.0 SbfsBlock
CALL sbfsStepWord
SETD.1 SbfsLeft
LDA.1
DECA
STA.1
BRA sbfsChildNone
BRI sbfsChildBlock
sbfsChildYes:
RSTA
RSTB
CCF
ADD ; Q is zero: something lives there.
RET
sbfsChildNone:
RSTA
INIB 0d1
CCF
ADD
RET
; ---- Deleting ----
;
; Frees a file. DP0 names it, and Q is zero if it went.
@@ -1627,6 +2094,16 @@ sbfsDelete:
AND
BNQ sbfsDeleteFailed
CALL sbfsWipeFound
RET
; Throws away whatever the last find landed on, wherever it landed. DP3 is on the entry and
; SbfsBlock is the directory block it came out of, which is everything needed to change it
; and put it back.
;
; Removing a directory arrives here too. A directory has no blocks, so the count that goes
; back to the free total is nought and the sum is right without being a special case.
sbfsWipeFound:
; How much room it was taking, worked out before the entry that says so is thrown away.
CALL sbfsFileExtent
@@ -1693,16 +2170,24 @@ sbfsRename:
SETD.2 SbfsSavedName
STD.0.2
; The new name is a path like any other, so only its last name is the name. Renaming
; something to "/A/notes.txt" used to call it that, all thirteen characters of it.
PSHD.1
POPD.0
CALL sbfsWalkParent
BNQ sbfsRenameFailed
SETD.0 SbfsAt
SETD.1 SbfsHoldAt
CALL sbfsCopyWord
SETD.0 SbfsWanted
SETD.1 SbfsNewName
CALL sbfsKeepName ; Twenty two bytes, padded, the way an entry holds one.
CALL sbfsCopyName
; Refused if something already answers to the new name. Two entries with one name is a
; disk that cannot be searched sensibly: a search answers with whichever it meets first,
; so the other becomes unreachable without ever having been deleted.
SETD.0 SbfsNewName
CALL sbfsFind
; Refused if something already answers to that name in that directory. Two entries with
; one name in one place is a directory that cannot be searched sensibly: a search answers
; with whichever it meets first, and the other becomes unreachable without ever having
; been deleted.
CALL sbfsScanFor
BRQ sbfsRenameFailed
SETD.2 SbfsSavedName
@@ -1710,6 +2195,14 @@ sbfsRename:
CALL sbfsFind
BNQ sbfsRenameFailed
; RENAMING DOES NOT MOVE ANYTHING. Only the twenty two bytes of the name change, and the
; parent is not among them - so a new path naming a different directory would be a lie
; the disk went along with. Refused instead.
SETD.0 SbfsUpParent
SETD.2 SbfsHoldAt
CALL sbfsCompareWord
BNQ sbfsRenameFailed
; Not a directory. Renaming one would be safe enough, but the shell has no way to make
; one yet, so allowing it here would only be a way to reach something that cannot be
; got at any other way.
@@ -1782,12 +2275,26 @@ sbfsSaveFile:
SETD.1 SbfsSaveTail
STA.1
; Refused up front if the name belongs to a directory. Left to itself the delete below
; would refuse it, the rename at the end would refuse it too, and the save would fail
; having already written a temporary that nothing would ever come back for.
; WHERE IT GOES, WORKED OUT ONCE. Everything below is done in terms of a directory and
; a name rather than a path, and that is what makes the careful order below work in a
; subdirectory: the temporary has to be made in the SAME directory as the file, because
; the rename at the end changes a name and does not move anything.
SETD.2 SbfsSaveName
LDD.0.2
CALL sbfsFind
CALL sbfsWalkParent
BNQ sbfsSaveFailed
SETD.0 SbfsAt
SETD.1 SbfsSaveParent
CALL sbfsCopyWord
SETD.0 SbfsWanted
SETD.1 SbfsSaveLeaf
CALL sbfsCopyName
; Refused up front if that name belongs to a directory. Left to itself the delete below
; would refuse it, the rename at the end would refuse it too, and the save would fail
; then instead, having already written a temporary nothing would ever come back for.
CALL sbfsSaveWhere
CALL sbfsScanFor
BNQ sbfsSaveNotThere
SETD.0 SbfsFoundFlags
LDA.0
@@ -1798,8 +2305,11 @@ sbfsSaveNotThere:
; A temporary left behind by a save that did not finish would be in the way. Whether
; there was one is not worth asking about, since either answer leads here.
SETD.0 SbfsTempName
CALL sbfsDelete
CALL sbfsSaveTemp
CALL sbfsScanFor
BNQ sbfsSaveNoTemp
CALL sbfsWipeFound
sbfsSaveNoTemp:
SETD.0 SbfsFileBlocks
SETD.2 SbfsSaveBlocks
@@ -1809,8 +2319,8 @@ sbfsSaveNotThere:
SETD.1 SbfsFileTail
STA.1
SETD.0 SbfsTempName
CALL sbfsCreate
CALL sbfsSaveTemp
CALL sbfsCreateAt
BNQ sbfsSaveFailed
SETD.2 SbfsSaveData
@@ -1818,16 +2328,51 @@ sbfsSaveNotThere:
CALL sbfsWriteFile
BNQ sbfsSaveFailed
; Now, and not before, the old one goes. It may not exist, which is what saving something
; for the first time looks like from here.
SETD.2 SbfsSaveName
LDD.0.2
CALL sbfsDelete
; Now, and not before, the old one goes. It may not be there at all, which is what
; saving something for the first time looks like from here.
CALL sbfsSaveWhere
CALL sbfsScanFor
BNQ sbfsSaveNoOld
CALL sbfsWipeFound
sbfsSaveNoOld:
; And the temporary takes its name. Found again first, because everything above has been
; reading other blocks over the one it lives in.
CALL sbfsSaveTemp
CALL sbfsScanFor
BNQ sbfsSaveFailed
PSHD.3
POPD.1
DPUP.1 0d06
SETD.0 SbfsSaveLeaf
CALL sbfsCopyName
SETD.1 SbfsBuffer
CALL sbfsBufferIn
CALL sbfsWriteBlock
RET
; The directory the file is going in, and the name it is going under. Said again before
; each step, because every step in between goes to the disk and leaves the walk somewhere
; else entirely.
sbfsSaveWhere:
SETD.0 SbfsSaveParent
SETD.1 SbfsAt
CALL sbfsCopyWord
SETD.0 SbfsSaveLeaf
SETD.1 SbfsWanted
CALL sbfsCopyName
RET
; The same directory, under the name the half finished file is written as.
sbfsSaveTemp:
SETD.0 SbfsSaveParent
SETD.1 SbfsAt
CALL sbfsCopyWord
SETD.0 SbfsTempName
SETD.2 SbfsSaveName
LDD.1.2
CALL sbfsRename
SETD.1 SbfsWanted
CALL sbfsKeepName
RET
sbfsSaveFailed:
@@ -1895,6 +2440,20 @@ SbfsCwd:
; plus one, so that zero is the root and a version one disk's zeroes already say it.
SbfsPathAt:
0x00 0x00
; ---- What splitting a path off its last name keeps ----
SbfsHead:
#Reserve 0d96
SbfsHeadLen:
0x00
SbfsHoldAt:
0x00 0x00
; The last name of a path, kept out of the way while the rest of the path is walked.
SbfsLeaf:
#Reserve 0d23
SbfsSpanAt:
0x00
SbfsPathState:
0x00
SbfsPathLeft:
@@ -1958,6 +2517,10 @@ SbfsSaveBlocks:
0x00 0x00
SbfsSaveTail:
0x00
SbfsSaveParent:
0x00 0x00
SbfsSaveLeaf:
#Reserve 0d23
; What a document is called while it is being written and is not yet the real thing. A
; name nothing else is likely to want, and short enough to leave room for a long one.
+3 -2
View File
@@ -187,13 +187,14 @@ The disk images tests read from are built first by `Tests/makedisks.sh`, using S
test that reads one is therefore checked against a filesystem written by different code from
the same written specification, rather than against itself.
`Tests/run.sh` drives that comparison. Four more scripts run alongside it, and each exists
`Tests/run.sh` drives that comparison. Five more scripts run alongside it, and each exists
because a recorded file cannot answer its question:
- **`Tests/disk.sh`** checks the disk tool on its own: files of every awkward size onto an image and off again, and the things the format says cannot happen refused rather than half done.
- **`Tests/terminal.sh`** checks what a recorded file cannot see. Piped output is buffered and flushed at exit, so a prompt shown before its answer is asked for and one shown an hour late produce identical files; and key mode only touches a terminal when there is one. Both have gone wrong here, and both were found by a person whose terminal stopped working rather than by anything in this suite. So it runs the emulator under a pseudo-terminal and asks directly: that a prompt arrives before input is read, that a keystroke arrives without Return, that the terminal is handed back however the machine dies, and that suspending and resuming leave it as they found it.
- **`Tests/native.sh`** checks the assembler that runs on SplitBit against the one that runs on the host, byte for byte, on a boot image and four loadable programs, and then on CosmOS and on itself, and then on the CosmOS that CosmOS built.
- **`Tests/docs.sh`** checks the manuals against the code: that every instruction has a row and every row is an instruction, that the counts in the headings are right, that every directive is written down, that every service the system implements is described and every service described is implemented, that every routine the manuals promise exists, and that the worked examples still assemble to the bytes printed beside them.
- **`Tests/agree.sh`** checks the two implementations of SBFS against each other rather than each against itself, by building the same disk with SplitDisk and with CosmOS and comparing the images byte for byte. Every field one of them writes and the other only reads is checked there and nowhere else.
- **`Tests/docs.sh`** checks the manuals against the code: that every instruction has a row and every row is an instruction, that the counts in the headings are right, that every directive is written down, that every service the system implements is described and every service described is implemented, that every routine the manuals promise exists, that CosmOS still fits in the half of the machine its memory map gives it, and that the worked examples still assemble to the bytes printed beside them.
A cycle count is deliberately **not** part of a recorded result. The last line of the emulator's output has the number taken out before anything is compared, keeping only whether the program stopped on its own or ran into its limit, which is behaviour. Two instructions added to CosmOS used to move that number in six unrelated files at once, so a real difference would have arrived in a crowd of meaningless ones. Anything that wants to measure cycles should say so in a test of its own.
Executable
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Checks the two implementations of SBFS against each other, on the same disk.
#
# SplitDisk and sbfs.asm are two programs written from one specification and sharing no
# code at all - one is C on the host, the other is SplitBit assembly running on the
# machine. disk.sh checks the host half against the format and run.sh checks the machine
# half against recorded output, but neither of those can catch the two of them agreeing
# with themselves and disagreeing with each other.
#
# SO THIS BUILDS THE SAME DISK TWICE, once with each, and compares the images byte for
# byte. Every field either one writes and the other only reads is checked here and nowhere
# else: which entry a thing lands in, which block, what a directory's unused fields hold,
# the version in the superblock, the free count. A disagreement in any of those is a disk
# one of them can read and the other cannot, and the way that is usually discovered is
# somebody's file coming back wrong months later.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$ROOT/Tests/build/agree"
ASM="$ROOT/Assembler"
TOOL="$ROOT/SplitDisk"
EMU="$ROOT/SplitBit"
PASS=0
FAIL=0
FAILED_NAMES=()
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RESET=""; RED=""; }
report() {
local mark="$1" name="$2" note="${3:-}"
if [ "$mark" = "ok" ]; then
PASS=$((PASS + 1)); printf " [%sok %s] %-24s %s\n" "$GREEN" "$RESET" "$name" "$note"
else
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %-24s %s\n" "$RED" "$RESET" "$name" "$note"
fi
}
for tool in "$ASM" "$TOOL" "$EMU"; do
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
done
rm -rf "$WORK"; mkdir -p "$WORK"
cd "$WORK" || exit 1
"$ASM" -I "$ROOT/Programs/CosmOS/Source" "$ROOT/Programs/CosmOS/Source/cosmos.asm" \
-o cosmos.bin >/dev/null 2>&1 || { echo "CosmOS would not assemble."; exit 1; }
echo "Checking the two SBFS implementations against each other."
# ---- The same tree, made both ways ----
#
# The order matters and is the same on both sides, because both allocate first fit and both
# take the first free entry: given the same operations in the same order they should reach
# the same bytes, and any difference is a real one rather than an artefact of the script.
"$TOOL" format host.img 128 2 >/dev/null
"$TOOL" mkdir host.img /Apps >/dev/null
"$TOOL" mkdir host.img /Apps/Deep >/dev/null
"$TOOL" mkdir host.img /Notes >/dev/null
"$TOOL" format machine.img 128 2 >/dev/null
printf 'mkdir /Apps\nmkdir /Apps/Deep\nmkdir /Notes\nexit\n' \
| "$EMU" cosmos.bin --fast --disk machine.img >/dev/null 2>&1
if cmp -s host.img machine.img; then
report ok "three directories" "byte for byte"
else
report FAIL "three directories" "$(cmp host.img machine.img 2>&1 | head -1)"
fi
# ---- Removing one puts the disk back exactly ----
#
# A wiped entry has to be indistinguishable from one that was never used, or a disk that
# has had something deleted stops matching a fresh one that never did. Both sides zero all
# thirty two bytes, and this is what says so.
"$TOOL" rmdir host.img /Apps/Deep >/dev/null
printf 'rmdir /Apps/Deep\nexit\n' | "$EMU" cosmos.bin --fast --disk machine.img >/dev/null 2>&1
if cmp -s host.img machine.img; then
report ok "and removing one" "byte for byte"
else
report FAIL "and removing one" "$(cmp host.img machine.img 2>&1 | head -1)"
fi
# ---- A file put down a path ----
#
# The machine writes files through the careful order a save has to use - make a temporary,
# write it, delete the original, rename the temporary - and the host writes the entry once.
# Two quite different routes to what has to be the same disk.
# The editor goes on both disks FIRST and by the same route, so that the only thing left
# differing is how the payload got written. Putting it on one disk before the payload and
# the other after was enough to move every entry and block after it, and the comparison
# duly failed on a difference the script had introduced.
"$ASM" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/CosmOS/Source" \
"$ROOT/Programs/CosmOS/Apps/Edit.asm" -o Edit.sbx >/dev/null 2>&1
"$TOOL" put host.img Edit.sbx >/dev/null
"$TOOL" put machine.img Edit.sbx >/dev/null
printf 'a file that lives in a directory\n' > payload.txt
"$TOOL" put host.img payload.txt /Notes/payload.txt >/dev/null
printf 'load Edit.sbx\nrun /Notes/payload.txt\na\na file that lives in a directory\n.\nw\nq\nexit\n' \
| "$EMU" cosmos.bin --fast --disk machine.img >/dev/null 2>&1
if cmp -s host.img machine.img; then
report ok "a file down a path" "byte for byte"
else
report FAIL "a file down a path" "$(cmp host.img machine.img 2>&1 | head -1)"
fi
# ---- And each can read what the other wrote ----
#
# Matching bytes and being readable are not the same claim. A field both of them write
# wrongly in the same way would pass every comparison above.
"$TOOL" get machine.img /Notes/payload.txt fromMachine.txt >/dev/null 2>&1
if cmp -s payload.txt fromMachine.txt; then
report ok "the host reads it back" "$(wc -c < fromMachine.txt | tr -d ' ') bytes"
else
report FAIL "the host reads it back" "the file came back different"
fi
"$TOOL" mkdir host.img /Notes/Inner >/dev/null
printf 'x' > deep.txt
"$TOOL" put host.img deep.txt /Notes/Inner/deep.txt >/dev/null
seen=$(printf 'cd /Notes/Inner\ndir\nexit\n' \
| "$EMU" cosmos.bin --fast --disk host.img 2>&1 | grep -c "deep.txt")
if [ "$seen" -ge 1 ]; then
report ok "the machine reads it back" "found it three deep"
else
report FAIL "the machine reads it back" "the machine could not see it"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS agreement checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1
+2
View File
@@ -4,6 +4,8 @@ load <file> read a program off the disk
run [words] start what was loaded, and tell it those words
<name> [words] look where you are and then in /Apps, and start that
cd [path] go to a directory, or to the root with nothing after it
mkdir <path> make a directory
rmdir <path> remove an empty one
delete <file> take it off the disk
rename <file> <to> call it something else
monitor look at memory, change it, and jump into it
+2 -2
View File
@@ -3,11 +3,11 @@ CosmOS
> two stops, and what the registers were at each
break at 400E
A 11 B 22 Q 00 status 00
DP0 2030 DP1 082E DP2 0000 DP3 4000 SP FFFF
DP0 2030 DP1 09BC DP2 0000 DP3 4000 SP FFFF
press a key
break at 4023
A 44 B 55 Q 00 status 00
DP0 2000 DP1 082E DP2 0000 DP3 4000 SP FFF5
DP0 2000 DP1 09BC DP2 0000 DP3 4000 SP FFF5
press a key
carried on to the end
finished
+27
View File
@@ -0,0 +1,27 @@
CosmOS
> 0 files
> made
> made
> made
> Apps <dir>
Notes <dir>
0 files, 2 directories
> /Apps> Deep <dir>
0 files, 1 directory
/Apps> cannot make that: check the path, the name, and whether it is taken
/Apps> made
/Apps> /Notes> Deep <dir>
0 files, 1 directory
/Notes> cannot remove that: it must be a directory, and empty
/Notes> removed
/Notes> that is a directory
/Notes> removed
/Notes> > cannot make that: check the path, the name, and whether it is taken
> cannot remove that: it must be a directory, and empty
> cannot remove that: it must be a directory, and empty
> removed
> removed
> 0 files
> halted
Execution halted.
[exit 0]
+2
View File
@@ -21,6 +21,8 @@ load <file> read a program off the disk
run [words] start what was loaded, and tell it those words
<name> [words] look where you are and then in /Apps, and start that
cd [path] go to a directory, or to the root with nothing after it
mkdir <path> make a directory
rmdir <path> remove an empty one
delete <file> take it off the disk
rename <file> <to> call it something else
monitor look at memory, change it, and jump into it
+23
View File
@@ -0,0 +1,23 @@
dir
mkdir Apps
mkdir Apps/Deep
mkdir Notes
dir
cd Apps
dir
mkdir Deep
mkdir /Notes/Deep
cd /Notes
dir
rmdir /Apps
rmdir /Apps/Deep
delete /Apps
rmdir /Notes/Deep
cd /
mkdir Apps/Deep/Inner
rmdir Apps/Deep/Inner
rmdir Apps/Deep
rmdir Apps
rmdir Notes
dir
exit
+6
View File
@@ -219,6 +219,12 @@ awk 'BEGIN { for (i = 0; i < 30; i++) printf "line %02d: ABCDEFGHIJKLMNOPQRSTUVW
printf 'this is not a program' > rooted.txt
"$TOOL" put "$DISKS/tree.img" rooted.txt >/dev/null
# A blank disk for the machine to build a tree on itself. It starts as a version ONE disk
# with nothing at all on it, because half of what this checks is that making the first
# directory raises the version - the number says what is on a disk rather than what made
# it, so a disk with no directories is flat whoever formatted it.
"$TOOL" format "$DISKS/build.img" 128 2 >/dev/null
# A disk for moving about on. Two directories hold a file of THE SAME NAME with different
# text in it, which is the fixture the working directory needs: "notes.txt" has to mean a
# different file from each of them, and the only way to see that it does is for the two to
+15
View File
@@ -343,6 +343,21 @@ cosmosTree | CosmOS/Source/cosmos.asm | run | cosmosTre
# hold no blocks and must therefore be in nobody's way when a run of free ones is wanted.
# The listing afterwards says where it landed and how big it is.
cosmosTreeWrite | CosmOS/Source/cosmos.asm | run | cosmosTreeWrite.in | - | disks/treewrite.img
# The machine building its own tree. It starts with a blank version one disk and makes
# every directory on it, which is the half of the filesystem the machine could only read
# until now.
#
# The refusals are most of the test. rmdir will not take a file and delete will not take a
# directory, so neither can be the one that removed more than was asked for; a directory
# with anything in it is refused outright, because a parent is an entry INDEX and a freed
# index goes to the next thing created - the children would turn up inside whatever took
# its place, with nothing pointing downward to find them by. A name already taken in that
# directory is refused, and the same name in a different directory is not, which is the
# whole point of the exercise.
#
# The last dir is there to show the disk still adds up: every entry made and unmade, and
# nothing left over.
cosmosBuild | CosmOS/Source/cosmos.asm | run | cosmosBuild.in | - | disks/build.img
# The working directory. cd moves the machine, the prompt says where it is once that is
# not the root, and dir lists one directory rather than the whole disk.
#
+4
View File
@@ -103,6 +103,8 @@ test: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) strict
@echo
@./Tests/native.sh
@echo
@./Tests/agree.sh
@echo
@./Tests/docs.sh
# Rebuild all three tools with the address and undefined behaviour sanitizers and run
@@ -137,6 +139,8 @@ sanitize:
@echo
@./Tests/native.sh
@echo
@./Tests/agree.sh
@echo
@./Tests/docs.sh
@$(MAKE) --no-print-directory clean
@$(MAKE) --no-print-directory