The console had grown an ANSI parser, and that was the wrong shape. ANSI exists because a screen used to be on the other end of a serial line and a byte stream was the only channel there was. This screen is memory the program can already address, so reaching it by sending characters for a state machine to take apart is a middleman for something the machine does better - and it meant accepting an open protocol somebody else defines, in hardware, with no natural end to it. Everything else on this machine is registers. So the console gets three: cursor row at 0x03, cursor column at 0x04, and a command port at 0x05 where 1 clears the screen. Both cursor registers are READ as well as written, which is the thing an escape cannot do without sending a query and parsing a reply - a routine that wants to put the cursor back where it found it can now ask. Clearing is one command against a thousand cells walked one at a time. Snake and Life are smaller for it: 2,168 bytes to 2,163 and 1,410 to 1,396. A HOST TERMINAL STILL SPEAKS ANSI, and bridging to the host is the emulator's job, the same job it does reading standard input. So the escapes are now GENERATED, outbound, for the set this device chooses, rather than parsed inbound as though the machine were a terminal. The set cannot grow behind our backs because we are the ones saying it. The cursor is announced lazily, at the next character rather than at the register write, so setting a row and a column costs one sequence rather than two. The console's block widens from three ports to six, which registryTest noticed: it had been asking about port 0x05 precisely BECAUSE nothing was there, and the console had just moved in. Re-blessing it would have left it checking nothing, so it asks about 0x80 instead - clear of the console, the disk, the screen, the controller, and the sound device coming to 0x40. Six checks in Tests/video.sh swapped from the sequences to the registers, including that the cursor reads back and that one sent past the edge is clamped rather than refusing. Those checks also stopped counting bytes from the ends of a file, which had quietly started measuring an escape the moment the console began announcing the cursor. SplitLint caught the one thing worth catching in the port: the clear command leaves A at 1 and key mode is also 1, so the second load looks redundant. Acting on it would tie a console command to a console mode by coincidence, and break silently if either ever moved, so it is suppressed with that reason rather than removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
385 lines
8.1 KiB
NASM
385 lines
8.1 KiB
NASM
; Conway's Game of Life, as an application CosmOS can load and run.
|
|
;
|
|
; Ported from gameOfLife/16x16LifeModern.asm. The simulation is unchanged: the same
|
|
; interleaved 18 by 18 board with a dead border, the same four-Data-Pointer rewrite of
|
|
; the neighbor count, and the same glider.
|
|
;
|
|
; What had to change is when it stops. On the bare machine this program never stopped,
|
|
; because Life has no end state to reach and nothing was waiting for the machine back.
|
|
; Under CosmOS a program owns the console until it returns, so a program that never ends
|
|
; takes the shell with it. There is no HALT to fall into and no key that can interrupt
|
|
; it: console input is a blocking read, so a running program cannot ask whether anybody
|
|
; has pressed anything.
|
|
;
|
|
; So it stops on its own, two ways:
|
|
;
|
|
; IT SETTLES. commitBoard already walks the current and next state of every cell side
|
|
; by side, so it can notice for free whether any of them differed. When none did, the
|
|
; board has reached a state it will stay in forever and there is nothing left to show.
|
|
; The glider does reach one: it crosses the field, runs into the dead border, and
|
|
; collapses into a block in the corner at generation 54.
|
|
;
|
|
; IT RUNS OUT. Settling catches still lifes and extinction. It does not catch an
|
|
; oscillator - a blinker would flip back and forth forever and never be "unchanged" -
|
|
; so there is a generation limit behind it. It is not meant to be the answer. It is
|
|
; there so that no seed anybody tries later can take the shell down with it.
|
|
;
|
|
; Note that #Include print.asm comes at the END of this file rather than the beginning.
|
|
; print.asm opens with a branch to start, which is what a boot image wants at address
|
|
; zero; a loadable program wants its own first instruction at its code base instead.
|
|
|
|
#Include services.asm
|
|
|
|
#Program
|
|
|
|
#Base 0x4000
|
|
|
|
start:
|
|
CALL seedGlider
|
|
INIA 0x01
|
|
OUTA 0x05 ; Console command: clear the screen
|
|
SETD.3 GenerationsLeft
|
|
INIA 0xFF
|
|
STA.3
|
|
|
|
; Key mode, so that one keypress is one byte and stops it. In line mode the terminal
|
|
; holds what is typed until Return, so nothing arrives until then and "press any key"
|
|
; would really mean "press any key and then Return". It is put back before this program
|
|
; returns; CosmOS puts it back too, in case a program stops without doing so.
|
|
INIA 0x01
|
|
OUTA 0x02
|
|
|
|
generationLoop:
|
|
CALL renderBoard
|
|
CALL evolveBoard
|
|
CALL commitBoard
|
|
|
|
; Has anybody asked it to stop? The status port answers without waiting, which is the
|
|
; whole reason it exists: reading the data port here would stop the simulation dead
|
|
; until somebody typed something, which is the opposite of what is wanted.
|
|
;
|
|
; READY is clear at the end of input as well as when nothing has been typed, so running
|
|
; with input from a file - which is how the tests run it - never stops here. It runs to
|
|
; the still life instead, and that is the right answer in both places.
|
|
INA 0x01
|
|
INIB 0x01 ; READY
|
|
AND
|
|
BRQ lifeNoKey
|
|
INA 0x00 ; Take the key, so it is not left waiting for the shell.
|
|
BRI lifeStopped
|
|
lifeNoKey:
|
|
|
|
; commitBoard leaves the flag set if any cell differed from what replaced it. DP3 is
|
|
; pointed at it again rather than trusting what the call left behind: RET does not put
|
|
; DP3 back, so its value after a call is the callee's business and not a promise.
|
|
SETD.3 BoardChanged
|
|
LDA.3
|
|
BRA lifeSettled
|
|
|
|
SETD.3 GenerationsLeft
|
|
LDA.3
|
|
DECA
|
|
STA.3
|
|
BRA lifeRanOut
|
|
|
|
CALL frameDelay
|
|
BRI generationLoop
|
|
|
|
; The three ways it can be over. Each one only picks the words; the tidying up is the same
|
|
; for all of them and is written once, which is also how the console cannot be left in key
|
|
; mode down one path and not another.
|
|
lifeStopped:
|
|
SETD.0 StoppedText
|
|
BRI lifeEnd
|
|
|
|
lifeSettled:
|
|
SETD.0 SettledText
|
|
BRI lifeEnd
|
|
|
|
lifeRanOut:
|
|
SETD.0 RanOutText
|
|
|
|
lifeEnd:
|
|
RSTA
|
|
OUTA 0x02 ; Line mode, the way it was found.
|
|
CALL lineFeed
|
|
CALL printString ; DP0 still holds the words: CALL puts DP0 back.
|
|
CALL lineFeed
|
|
RSTA
|
|
SWI osExit
|
|
|
|
seedGlider:
|
|
SETD.0 Board
|
|
DPUP.0 0d42
|
|
INIA 0x01
|
|
STA.0
|
|
SETD.0 Board
|
|
DPUP.0 0d80
|
|
STA.0
|
|
SETD.0 Board
|
|
DPUP.0 0d112
|
|
STA.0
|
|
DPUP.0 0d02
|
|
STA.0
|
|
DPUP.0 0d02
|
|
STA.0
|
|
RET
|
|
|
|
renderBoard:
|
|
RSTA
|
|
OUTA 0x03
|
|
OUTA 0x04 ; Cursor to row 0, column 0
|
|
SETD.1 RowCount
|
|
SETD.2 ColCount
|
|
INIA 0d16
|
|
STA.1
|
|
SETD.0 Board
|
|
DPUP.0 0d38
|
|
|
|
renderRow:
|
|
INIA 0d16
|
|
STA.2
|
|
renderCell:
|
|
LDA.0
|
|
BRA renderDead
|
|
INIB 0x23
|
|
OUTB 0x00
|
|
BRI renderCellDone
|
|
renderDead:
|
|
INIB 0x20
|
|
OUTB 0x00
|
|
renderCellDone:
|
|
DPUP.0 0d02
|
|
LDA.2
|
|
DECA
|
|
STA.2
|
|
BRA renderRowDone
|
|
BRI renderCell
|
|
|
|
renderRowDone:
|
|
CALL lineFeed
|
|
DPUP.0 0d04
|
|
LDA.1
|
|
DECA
|
|
STA.1
|
|
BRA renderDone
|
|
BRI renderRow
|
|
renderDone:
|
|
RET
|
|
|
|
evolveBoard:
|
|
SETD.1 RowCount
|
|
SETD.2 ColCount
|
|
INIA 0d16
|
|
STA.1
|
|
SETD.0 Board
|
|
DPUP.0 0d38
|
|
|
|
evolveRow:
|
|
INIA 0d16
|
|
STA.2
|
|
evolveCellLoop:
|
|
CALL evolveCell
|
|
DPUP.0 0d02
|
|
LDA.2
|
|
DECA
|
|
STA.2
|
|
BRA evolveRowDone
|
|
BRI evolveCellLoop
|
|
|
|
evolveRowDone:
|
|
DPUP.0 0d04
|
|
LDA.1
|
|
DECA
|
|
STA.1
|
|
BRA evolveDone
|
|
BRI evolveRow
|
|
evolveDone:
|
|
RET
|
|
|
|
evolveCell:
|
|
CALL countNeighbors
|
|
MVQB ; B is the neighbor count from here down.
|
|
|
|
; Three neighbors always produces a live cell.
|
|
INIA 0d03
|
|
CCF
|
|
SUB
|
|
BRQ makeAlive
|
|
|
|
; Two neighbors preserve the current state.
|
|
INIA 0d02
|
|
CCF
|
|
SUB
|
|
BRQ preserveCell
|
|
|
|
makeDead:
|
|
RSTA
|
|
INCD.0
|
|
STA.0
|
|
DECD.0
|
|
RET
|
|
|
|
preserveCell:
|
|
LDA.0
|
|
BRA makeDead
|
|
|
|
makeAlive:
|
|
INIA 0x01
|
|
INCD.0
|
|
STA.0
|
|
DECD.0
|
|
RET
|
|
|
|
; Return the eight-neighbor sum in Q. One Stack round-trip copies DP0 into
|
|
; volatile DP3; MVQA then keeps the running total entirely in registers.
|
|
countNeighbors:
|
|
PSHD.0
|
|
POPD.3
|
|
RSTA
|
|
DPDN.3 0d38
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d02
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d02
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d32
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d04
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d32
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d02
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
DPUP.3 0d02
|
|
|
|
LDB.3
|
|
CCF
|
|
ADD
|
|
RET
|
|
|
|
; Copies each cell's next state over its current one, and says whether any of them
|
|
; differed. The comparison is what the bare metal version did not need: it is one XOR
|
|
; on two bytes that are already in registers, in a loop that was already visiting every
|
|
; cell, which is why "has it settled" costs almost nothing to ask.
|
|
;
|
|
; DP3 holds the flag for the whole walk. commitBoard calls nothing, so nothing else can
|
|
; want DP3 while it works.
|
|
commitBoard:
|
|
SETD.3 BoardChanged
|
|
RSTA
|
|
STA.3
|
|
SETD.1 RowCount
|
|
SETD.2 ColCount
|
|
INIA 0d18
|
|
STA.1
|
|
SETD.0 Board
|
|
|
|
commitRow:
|
|
INIA 0d18
|
|
STA.2
|
|
commitCell:
|
|
LDB.0 ; The cell as it stands.
|
|
INCD.0
|
|
LDA.0 ; The cell as it is about to stand.
|
|
DECD.0
|
|
STA.0
|
|
XOR ; Q is zero only if those two were the same.
|
|
BRQ commitSame
|
|
INIA 0x01
|
|
STA.3 ; Something moved, so this is not the last generation.
|
|
commitSame:
|
|
DPUP.0 0d02
|
|
LDA.2
|
|
DECA
|
|
STA.2
|
|
BRA commitRowDone
|
|
BRI commitCell
|
|
|
|
commitRowDone:
|
|
LDA.1
|
|
DECA
|
|
STA.1
|
|
BRA commitDone
|
|
BRI commitRow
|
|
commitDone:
|
|
RET
|
|
|
|
frameDelay:
|
|
INIA 0xFF
|
|
delayOuter:
|
|
INIB 0xFF
|
|
delayInner:
|
|
DECB
|
|
BRB delayInnerDone
|
|
BRI delayInner
|
|
delayInnerDone:
|
|
DECA
|
|
BRA delayDone
|
|
BRI delayOuter
|
|
delayDone:
|
|
RET
|
|
|
|
#Data
|
|
|
|
#Base 0x2000
|
|
|
|
RowCount:
|
|
0x00
|
|
ColCount:
|
|
0x00
|
|
|
|
; Cleared at the top of every commitBoard and set by any cell that changed, so after a
|
|
; commit it describes that generation and no other.
|
|
BoardChanged:
|
|
0x00
|
|
; Counts down. One byte is enough for a limit that is not meant to be reached.
|
|
GenerationsLeft:
|
|
0x00
|
|
|
|
SettledText:
|
|
"the board has settled"
|
|
RanOutText:
|
|
"stopped: still changing after 255 generations"
|
|
StoppedText:
|
|
"stopped"
|
|
|
|
|
|
; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The
|
|
; original leaves this implicit and leans on Data Memory being zero, which works but
|
|
; means the assembler believes the board is one byte long: anything placed after it
|
|
; would land inside it, and nothing would say so. Reserving the region states how far
|
|
; it reaches, so a label added below here is safe.
|
|
Board:
|
|
#Reserve 0d648
|
|
|
|
#Include print.asm
|