Let a script run a script, four deep

A build script calling a setup script is the first thing anybody tries.

What is saved when one script starts another is A POSITION AND NOT A
BUFFER: the name, which block comes next, how many are left, and where in
the block it had got to. Seventy bytes, and they sit next to each other in
the data segment on purpose so that saving them is one copy. The block
itself is read again on the way back, which costs one disk read per return
and saves 257 bytes a level - the inner script reads its own block into the
single buffer there is, so coming back means fetching the outer one's block
again and landing on the byte it left.

The slot is reached by stepping rather than by multiplying, because this
machine has no multiply and the depth is never more than three steps.

Four levels. Deep enough for a script calling a script that calls a helper,
shallow enough that a script running itself says so rather than filling
memory. A line that fails now stops every level and not just the innermost,
because a build whose helper failed should not carry on in its caller.

The caller's place is saved BEFORE the new file is looked at, and put back
on every way out that is not success. Opening writes the name into the live
state in order to ask the disk about it, so by the time "there is no such
file" is known, the caller's place has already been overwritten - a failed
'do' inside a script would otherwise leave the script that ran it reading
from a name it never chose.

The test resumes in the outer script's SECOND block, which is the case the
whole design turns on and the one an ordinary nesting test would miss.
Breaking the re-read, the save, or the limit each fails it.

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-30 16:22:09 -04:00
co-authored by Claude Opus 5
parent a12d61fb80
commit c28826df77
10 changed files with 234 additions and 14 deletions
+9 -2
View File
@@ -170,8 +170,15 @@ it, so the next line comes from the console again.
The interactive assembler reads its lines the same way, so a script can contain a block of The interactive assembler reads its lines the same way, so a script can contain a block of
assembly and end it with a `.` just as you would by hand. assembly and end it with a `.` just as you would by hand.
**A script cannot yet run another script.** That is the next thing, and it wants a stack of **A script can run another script, four deep.** What is remembered when one script starts
positions rather than the single one the reader keeps today. another is a position and not a buffer - the name, which block comes next, how many are left,
and where in the block it had got to. The block itself is read again on the way back, which
costs one disk read per return and saves a 257-byte buffer per level. Four is deep enough for
a script calling a script that calls a helper, and shallow enough that a script which runs
itself says `do: scripts are only four deep` rather than filling memory.
**A line that fails stops every level**, not just the innermost. A build whose helper script
failed should not carry on in the script that called the helper.
## Shell Commands: ## Shell Commands:
+16 -4
View File
@@ -140,7 +140,7 @@ prompt:
; comes back to. A build whose first step failed and whose second step ran anyway produces ; comes back to. A build whose first step failed and whose second step ran anyway produces
; something wrong and says it succeeded, which is the failure this whole flag exists to ; something wrong and says it succeeded, which is the failure this whole flag exists to
; prevent. ; prevent.
SETD.1 ScriptRunning SETD.1 ScriptDepth
LDA.1 LDA.1
BRA promptWhere BRA promptWhere
SETD.1 LineFailed SETD.1 LineFailed
@@ -149,7 +149,9 @@ prompt:
SETD.0 ScriptStopped SETD.0 ScriptStopped
CALL printString CALL printString
CALL newLine CALL newLine
CALL scriptClose ; Every level, not just this one. A build whose helper script failed should not carry on
; in the script that called the helper either.
CALL scriptAbandon
promptWhere: promptWhere:
; Where you are, but only when that is not obvious. At the root the prompt is the one it ; Where you are, but only when that is not obvious. At the root the prompt is the one it
@@ -320,11 +322,12 @@ promptSay:
; is nobody there and the shell should stop; a script ending means go back to whoever asked ; is nobody there and the shell should stop; a script ending means go back to whoever asked
; for it. So the end of a script falls through to the console rather than to the door. ; for it. So the end of a script falls through to the console rather than to the door.
shellReadLine: shellReadLine:
SETD.1 ScriptRunning SETD.1 ScriptDepth
LDA.1 LDA.1
BRA shellReadTyped BRA shellReadTyped
CALL scriptLine CALL scriptLine
BNQ shellReadTyped BNQ shellReadLine ; That one ended. Ask again: something under it may still be
; running, and only depth reaching nought means the console.
; Echoed, so that a script working can be watched and a script failing says where. It is ; Echoed, so that a script working can be watched and a script failing says where. It is
; printed after the prompt, so it reads exactly like somebody typing it. ; printed after the prompt, so it reads exactly like somebody typing it.
@@ -1624,6 +1627,13 @@ doScript:
CCF CCF
SUB SUB
BRQ scriptNoFile BRQ scriptNoFile
INIB 0x02
CCF
SUB
BRQ scriptNotOne
SETD.0 ScriptTooDeep
BRI fileComplain
scriptNotOne:
SETD.0 ScriptNotOne SETD.0 ScriptNotOne
BRI fileComplain BRI fileComplain
scriptNoFile: scriptNoFile:
@@ -3628,6 +3638,8 @@ ScriptNoFile:
"do: cannot find it" "do: cannot find it"
ScriptNotOne: ScriptNotOne:
"do: that is not a script - it wants #! on the first line" "do: that is not a script - it wants #! on the first line"
ScriptTooDeep:
"do: scripts are only four deep"
ScriptStopped: ScriptStopped:
"stopped: that line did not work" "stopped: that line did not work"
Unknown: Unknown:
+144 -5
View File
@@ -4,6 +4,7 @@
; CALL scriptOpen Q = 0 and a script is running, or Q says what was wrong: ; CALL scriptOpen Q = 0 and a script is running, or Q says what was wrong:
; 1 there is no such file ; 1 there is no such file
; 2 it is not a script - no #! on the front ; 2 it is not a script - no #! on the front
; 3 too many scripts inside each other
; DP0 = a buffer, B = how much room ; DP0 = a buffer, B = how much room
; CALL scriptLine Q = 0 and there is a line in the buffer, or nonzero at the end ; CALL scriptLine Q = 0 and there is a line in the buffer, or nonzero at the end
; ;
@@ -31,6 +32,87 @@
#Program #Program
; ---- One script inside another ----
;
; A build script calling a setup script is the first thing anybody tries, so what is saved
; when one script starts another is a POSITION AND NOT A BUFFER. The whole state of a
; running script is its name, which block comes next, how many are left, and where in the
; block it is - seventy bytes, laid out next to each other below so that saving it is one
; copy. The block itself is read again on the way back, which costs one disk read per return
; and saves 257 bytes a level.
;
; Four levels. Deep enough for a script calling a script that calls a helper, and shallow
; enough that a script which runs itself says so instead of filling memory.
scriptPush:
CALL scriptSlotAt
SETD.0 ScriptName
PSHD.3
POPD.1
CALL scriptCopyState
RET
scriptPop:
CALL scriptSlotAt
PSHD.3
POPD.0
SETD.1 ScriptName
CALL scriptCopyState
; ScriptAt points into the block buffer, which now holds somebody else's block. Reading
; it back is what makes the saved pointer mean what it meant.
CALL scriptReread
RET
; DP3 = where the script one level up is remembered. Reached by stepping rather than by
; multiplying, because this machine cannot multiply and the depth is never more than three
; steps. DP3 because RET puts the others back.
scriptSlotAt:
SETD.3 ScriptSaved
SETD.2 ScriptDepth
LDA.2
DECA
BRA scriptSlotDone
scriptSlotStep:
DPUP.3 0d70
DECA
BNA scriptSlotStep
scriptSlotDone:
RET
; Seventy bytes, DP0 to DP1.
scriptCopyState:
INIB 0d70
scriptCopyByte:
LDA.0
STA.1
INCD.0
INCD.1
DECB
BNB scriptCopyByte
RET
; The block that is meant to be in the buffer, back in the buffer. ScriptIndex is the NEXT
; one, so the one being read from is the one before it.
scriptReread:
SETD.1 ScriptIndex
LDA.1
INCD.1
LDB.1
DECB
BNC scriptRereadGo
DECA
scriptRereadGo:
SETD.0 ScriptName
SETD.1 ScriptBlock
SWI osFileBlock
SETD.1 ScriptBlock
PSHD.3
POPB
POPA
DPUW.1
RSTA
STA.1
RET
; ---- Opening ---- ; ---- Opening ----
; ;
; The name is COPIED rather than remembered by address. osFileBlock is given the name again ; The name is COPIED rather than remembered by address. osFileBlock is given the name again
@@ -39,6 +121,25 @@
; must stay valid; here it cannot, because the thing that reads the next line is the reason ; must stay valid; here it cannot, because the thing that reads the next line is the reason
; the name is needed. ; the name is needed.
scriptOpen: scriptOpen:
; ---- Four deep and no further ----
SETD.1 ScriptDepth
LDA.1
INIB 0d4
CCF
SUB
BRQ scriptOpenTooDeep
; ---- The one already running is put somewhere safe FIRST ----
;
; Before anything below overwrites it, and put back again on every way out of here that is
; not success. Opening writes the name into the live state to ask the disk about it, so by
; the time the answer is known the caller's place is already gone.
;
; A still holds the depth from the check above: SUB writes Q and leaves it alone.
BRA scriptOpenFirst
CALL scriptPush
scriptOpenFirst:
SETD.1 ScriptName SETD.1 ScriptName
INIB 0d63 INIB 0d63
CALL copyText CALL copyText
@@ -92,15 +193,29 @@ scriptOpenThere:
; Past the shebang line, wherever it ends. ; Past the shebang line, wherever it ends.
CALL scriptSkipLine CALL scriptSkipLine
INIA 0x01 SETD.1 ScriptDepth
SETD.1 ScriptRunning LDA.1
INCA
STA.1 STA.1
RSTA RSTA
BRI scriptOpenFailed ; A is nought, which is the answer for "it opened". BRI scriptOpenAnswer ; A is nought, which is the answer for "it opened".
scriptOpenTooDeep:
INIA 0x03
BRI scriptOpenAnswer ; Nothing was pushed, so there is nothing to put back.
scriptOpenNotOne: scriptOpenNotOne:
INIA 0x02 INIA 0x02
scriptOpenFailed: scriptOpenFailed:
; Whatever was running is still running, and its place is in the slot rather than in the
; live state. A is the answer and must survive being put back.
SETD.1 ScriptDepth
LDB.1
BRB scriptOpenAnswer
PSHA
CALL scriptPop
POPA
scriptOpenAnswer:
; Q is the answer, and A holds it. Adding nought is how a register becomes Q. ; Q is the answer, and A holds it. Adding nought is how a register becomes Q.
RSTB RSTB
CCF CCF
@@ -322,16 +437,35 @@ scriptSkipLine:
scriptSkipDone: scriptSkipDone:
RET RET
; One script ending. Whatever asked for it carries on, if anything did.
scriptClose: scriptClose:
SETD.1 ScriptDepth
LDA.1
BRA scriptCloseNone
DECA
STA.1
BRA scriptCloseNone
CALL scriptPop
scriptCloseNone:
RET
; Every script ending at once, which is what a line that did not work means. A build whose
; helper failed should not carry on in the script that called the helper either.
scriptAbandon:
RSTA RSTA
SETD.1 ScriptRunning SETD.1 ScriptDepth
STA.1 STA.1
RET RET
#Data #Data
ScriptRunning: ScriptDepth:
0x00 0x00
; ---- Seventy bytes, and they are next to each other on purpose ----
;
; Name, blocks left, next block, where in the block: the whole of where a script has got to.
; Saving it is one copy because of this order, and nothing else may be put between them.
ScriptName: ScriptName:
#Reserve 0d64 #Reserve 0d64
ScriptBlocks: ScriptBlocks:
@@ -340,6 +474,11 @@ ScriptIndex:
0x00 0x00 0x00 0x00
ScriptAt: ScriptAt:
0x00 0x00 0x00 0x00
; Three would do - a save happens on the second script and not the first - but four costs
; seventy bytes and removes an off-by-one from the only place it could hide.
ScriptSaved:
#Reserve 0d280
ScriptInto: ScriptInto:
0x00 0x00 0x00 0x00
ScriptRoom: ScriptRoom:
+1 -1
View File
@@ -79,7 +79,7 @@ from `make`, not from here.
### 1. Recorded output ### 1. Recorded output
`Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares `Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares
everything it printed against a file in `Tests/expected`. 174 tests, of which 112 run, 35 everything it printed against a file in `Tests/expected`. 175 tests, of which 113 run, 35
only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image
given at all. given at all.
+4 -1
View File
@@ -13,7 +13,10 @@ bad.script 45
plain.script 24 plain.script 24
cross.script 280 cross.script 280
nonl.script 38 nonl.script 38
13 files outer.script 376
inner.script 44
loop.script 35
16 files
> load what? > load what?
> no such file > no such file
> not a program > not a program
+27
View File
@@ -0,0 +1,27 @@
CosmOS
> > echo outer in block one
outer in block one
> do inner.script
> echo inner one
inner one
> echo inner two
inner two
> echo outer resumed in block one
outer resumed in block one
> > echo self
self
> do loop.script
> echo self
self
> do loop.script
> echo self
self
> do loop.script
> echo self
self
> do loop.script
do: scripts are only four deep
stopped: that line did not work
> halted
Execution halted.
[exit 0]
+4 -1
View File
@@ -12,7 +12,10 @@ bad.script 45
plain.script 24 plain.script 24
cross.script 280 cross.script 280
nonl.script 38 nonl.script 38
13 files outer.script 376
inner.script 44
loop.script 35
16 files
> loaded, starting at 4000 > loaded, starting at 4000
> it says: the disk took its time > it says: the disk took its time
finished finished
+3
View File
@@ -0,0 +1,3 @@
do outer.script
do loop.script
exit
+20
View File
@@ -135,6 +135,26 @@ open('cross.script','w').write(head + pad + 'Say across the block boundary\n')
# The last line has no newline after it and still has to run. # The last line has no newline after it and still has to run.
printf '#! script\nSay with no newline after me' > nonl.script printf '#! script\nSay with no newline after me' > nonl.script
"$TOOL" put "$DISKS/cosmos.img" nonl.script >/dev/null "$TOOL" put "$DISKS/cosmos.img" nonl.script >/dev/null
# ---- One script inside another ----
#
# outer.script resumes in its SECOND block, which is the case the whole design turns on: the
# inner script reads its own block into the one buffer there is, so coming back means reading
# the outer one's block again and landing on the byte it left off at. Generated, because
# where the do line falls is the entire point and no editor should be able to move it.
python3 -c "
head = '#! script\n'
pad = ''
while len(head) + len(pad) < 300:
pad += '; pad\n'
open('outer.script','w').write(head + pad +
'echo outer in block one\ndo inner.script\necho outer resumed in block one\n')
"
"$TOOL" put "$DISKS/cosmos.img" outer.script >/dev/null
printf '#! script\necho inner one\necho inner two\n' > inner.script
"$TOOL" put "$DISKS/cosmos.img" inner.script >/dev/null
# A script that runs itself, which is what the depth limit is for.
printf '#! script\necho self\ndo loop.script\n' > loop.script
"$TOOL" put "$DISKS/cosmos.img" loop.script >/dev/null
# A disk of its own for the writing test, with one file already on it so that what it # A disk of its own for the writing test, with one file already on it so that what it
# writes has to be placed somewhere that does not tread on what is there. # writes has to be placed somewhere that does not tread on what is there.
+6
View File
@@ -775,6 +775,12 @@ cosmosScriptBad | CosmOS/Source/cosmos.asm | run | cosmosScr
# the boundary between two blocks, and a last line with no newline after it. Both of these # the boundary between two blocks, and a last line with no newline after it. Both of these
# were faults before they were checks. # were faults before they were checks.
cosmosScriptEdges | CosmOS/Source/cosmos.asm | run | cosmosScriptEdges.in | 200000000 | disks/cosmos.img cosmosScriptEdges | CosmOS/Source/cosmos.asm | run | cosmosScriptEdges.in | 200000000 | disks/cosmos.img
# One script inside another, and the limit on how far that goes. The outer script resumes in
# its second block, which is the case the design turns on - the inner one reads its own block
# into the single buffer, so coming back means reading the outer one's again and landing on
# the byte it left. Then a script that runs itself, which stops at four deep and takes every
# level with it, because a build whose helper failed should not carry on in its caller.
cosmosScriptNest | CosmOS/Source/cosmos.asm | run | cosmosScriptNest.in | 200000000 | disks/cosmos.img
printDecimalTest | testPrograms/printDecimalTest.asm | xfail | - | - printDecimalTest | testPrograms/printDecimalTest.asm | xfail | - | -
printDigitTest | testPrograms/printDigitTest.asm | xfail | - | - printDigitTest | testPrograms/printDigitTest.asm | xfail | - | -
printHexTest | testPrograms/printHexTest.asm | xfail | - | - printHexTest | testPrograms/printHexTest.asm | xfail | - | -