-
-
Save robey/1bb6a99cd19e95c81979b1828ad70612 to your computer and use it in GitHub Desktop.
; | |
; the "monitor ROM" of an apple 1 fit in one page (256 bytes). | |
; | |
; this is my attempt to take the disassembled code, give names to the | |
; variables and routines, and try to document how it worked. | |
; | |
; | |
; an apple 1 had 8KB of RAM (more, if you hacked on the motherboard), and a | |
; peripheral chip that drove the keyboard and video. the video was run by a | |
; side processor that could treat the display as an append-only terminal that | |
; displayed ASCII characters $20 - $5F (uppercase Latin letters, digits, and | |
; basic punctuation). the apple 1 hardware is described in lucious detail | |
; here: https://www.applefritter.com/replica/chapter7 | |
; | |
; this ROM initialized the two peripherals, then displayed a prompt ('\' | |
; followed by a linefeed), and waited for a text command. | |
; | |
; the commands are: | |
; <addr>R | |
; execute code starting at <addr>. to restart the monitor ROM, you | |
; could do: "FF00R" | |
; <addr>.<addr> | |
; dump out hex values from RAM or ROM, between the two <addr> | |
; inclusive. the dot and second address can be omitted. for example, | |
; "FF00.FF03" would display: | |
; FF00: D8 58 A0 7F | |
; <addr>: <bytes...> | |
; write data into RAM. for example: "300: 20 00 FF" (which writes | |
; the instruction "JSR FF00" starting at $300) | |
; | |
; any character less than '.' (including space) is ignored. addresses and | |
; data are always in hex. because there is no backspace, the input routine | |
; treats '_' as if it erased the previous character. | |
; | |
; this would be enough to enter the 4KB of integer BASIC into RAM and then | |
; execute it, if you wanted to stay up all night doing that. in practice, | |
; most people added a cassette interface and loaded BASIC using a tape | |
; recorder: | |
; https://commons.wikimedia.org/wiki/File:Original_1976_Apple_1_Computer_In_A_Briefcase.JPG | |
; | |
; the constrained space means woz used a lot of tricks, like having a routine | |
; "fall thru" to another instead of calling it explicitly. to take another | |
; example, the "state" variable is carefully manipulated so that the top two | |
; bits indicate one of 3 states: normal, '.' has been parsed (hex dump mode), | |
; or ':' has been parsed (hex entry mode). these top two bits can be checked | |
; by performing BIT and branching on bit 7 N (BPL/BMI) or bit 6 V (BVC/BVS). | |
; | |
; the code for reaching the end of a parsed hex string is particularly | |
; byzantine: if the state is 0, this is the first address entered, so it's | |
; copied into both addr0 (for "run" or a hex dump) and addr1 (for hex data | |
; entry). then, it falls thru into the hex dump code to print out the first | |
; byte at that address before jumping back into the parser. so even if it's | |
; parsing a "run" or hex data entry, it will print out the (old) first byte. | |
; | |
; i suspect that the use of 3 separate address entries is unnecessary: "run" | |
; and hex data entry could probably use same address slot. however, this may | |
; not save more than 2 bytes of code. | |
; | |
; you can play with a fun online emulator here: https://www.scullinsteel.com/apple1/ | |
; | |
org $ff00 | |
buffer equ $0200 ; text input buffer ($200-$27F) | |
addr0l equ $24 ; target address for "run", or start of hex dump | |
addr0h equ $25 | |
addr1l equ $26 ; target address for hex data entry mode | |
addr1h equ $27 | |
addr2l equ $28 ; stores hex data as it's parsed | |
addr2h equ $29 | |
saveidx equ $2a ; temp storage for Y (buffer index) while parsing | |
state equ $2b ; normally 0, but: | |
; '.' ($ae = 10101110) if a dot was seen | |
; ':'<<1 ($ba -> $74 = 01110100) if a colon was seen | |
; so bit 7 (N) says we're in dot mode, and bit 6 (V) | |
; says we're in colon mode | |
; peripheral I/O ports: only 4 of them exist on the apple 1! | |
kbd equ $d010 ; read key | |
kbdcr equ $d011 ; control port | |
dsp equ $d012 ; write ascii | |
dspcr equ $d013 ; control port | |
; $ff00 | |
start: | |
cld | |
cli | |
ldy #$7f | |
sty dsp ; 01111111 - all periphs are output except highest bit | |
lda #$a7 ; 10100111 - configure both periphs | |
sta kbdcr | |
sta dspcr | |
; this is too clever. here, A=$a7 Y=$7f, so ckmeta will behave as if the | |
; buffer is at index 127 and the user entered a single quote ('). when it | |
; increments the index (to 128), that will overflow, falling into the abort | |
; routine, which prints a backslash and a linefeed and clears the buffer. | |
; | |
; this also means that the text input buffer is only 128 chars long (a bit | |
; over 3 lines on the screen). | |
; $ff0f | |
; handle special keys (backspace and esc) | |
ckmeta: | |
cmp #$df ; '_' -- woz treated this as a backspace | |
beq backsp | |
cmp #$9b ; ESC | |
beq abort | |
iny | |
bpl readch ; if we overran 128 bytes, fall thru to abort | |
; $ff1a | |
abort: | |
lda #$dc ; '\' | |
jsr cout | |
; $ff1f | |
; Y: buffer index | |
readline: | |
lda #$8d ; CR | |
jsr cout | |
ldy #1 ; fall thru, which will decr Y to 0, and into readch | |
; $ff26 | |
; there's no "cursor" on the apple 1 (the display behaves like a printer | |
; terminal), so it decrements the buffer index, and the '_' on screen is your | |
; indicator that a character was erased. | |
; for example, seeing "RUM_N" means we have "RUN" in the buffer. | |
backsp: | |
dey | |
bmi readline ; backspace too far and we'll reset | |
; $ff29 | |
readch: | |
; wait for high bit to be set on kbdcr, meaning there's a key on kbd | |
lda kbdcr | |
bpl readch | |
lda kbd | |
sta buffer,y | |
jsr cout | |
cmp #$8d ; CR | |
bne ckmeta | |
; we're 25% of the way through the ROM and so far we can only initialise | |
; and read a line of input text into a buffer. this ROM is *tight*. | |
; $ff3b | |
; process the input line: A=0 X=0 Y=0 | |
; fall thru to writing 0 into state and begin parsing | |
ldy #$ff | |
lda #0 | |
tax | |
; $ff40 | |
wstate1: | |
asl | |
; $ff41 | |
wstate: | |
sta state | |
; $ff43 | |
; A: current char being parsed | |
; X: 0 always | |
; Y: index into buffer | |
parsech: | |
iny | |
parsech1: | |
lda buffer,y | |
cmp #$8d ; CR | |
beq readline | |
cmp #$ae ; '.' | |
bcc parsech ; ignore anything < '.' | |
beq wstate ; remember the '.' and continue | |
cmp #$ba ; ':' | |
beq wstate1 ; shift it to $74 and remember it as the state | |
cmp #$d2 ; 'R' | |
beq run | |
stx addr2l ; assume it's a hex digit, clear addr2 | |
stx addr2h | |
sty saveidx | |
; $ff5f | |
; as long as there are hex digits in the buffer, roll them into addr2 | |
parsehex: | |
lda buffer,y | |
eor #$b0 ; xor with '0': if it's a digit, A is now 0-9. | |
cmp #10 | |
bcc digit ; <10? it was a digit | |
; if A was 'A'-'F' ($C1-$C6), the xor made it $71-$76 and carry is set. | |
; adding $89 will bring it up to $FA-$FF. a simple "<$FA" compare will then | |
; detect the hex range. | |
adc #$88 | |
cmp #$fa | |
bcc donehex ; not 'A'-'F' | |
; $ff6e | |
; digits enter as $00-$09, hex enters as $FA-$FF | |
digit: | |
asl ; shift the low nybble into the high nybble | |
asl | |
asl | |
asl | |
; roll the high nybble thru the carry bit into addr2 | |
ldx #4 | |
rollnyb: | |
asl | |
rol addr2l | |
rol addr2h | |
dex | |
bne rollnyb | |
iny | |
bne parsehex | |
; $ff7f | |
donehex: | |
cpy saveidx | |
beq abort ; if we processed 0 hex digits, this was an error | |
bit state ; bit 7 (N) = dot mode, bit 6 (V) = colon mode | |
bvc ckdot ; not colon mode | |
; colon mode is hex data entry. assume only one byte was entered (or use the | |
; low byte of whatever was entered), store that into addr1, and incr addr1 | |
lda addr2l | |
sta (addr1l,x) | |
inc addr1l | |
bne parsech1 | |
inc addr1h | |
; $ff91 | |
gojump: | |
jmp parsech1 | |
; $ff94 | |
run: | |
jmp (addr0l) | |
; $ff97 | |
ckdot: | |
bmi dotmode ; dot mode | |
; $ff98 | |
; not dot or colon mode, so this is the first hex value entered. copy it into | |
; addr1 and addr2, leaving X=0 again by the end. | |
firstaddr: | |
ldx #2 | |
; $ff9b | |
addrcopy: | |
lda addr2l-1,x | |
sta addr1l-1,x | |
sta addr0l-1,x | |
dex | |
bne addrcopy | |
; $ffa4 | |
; first time thru, the "bne" is skipped and the current address is printed. | |
; on subsequent rounds, A will be 0 if the address ends with 0 or 8, which | |
; will cause a new linefeed + address to be printed, making it look pretty. | |
prnext: | |
bne dumpbyte | |
; print a linefeed, addr0, ':', space | |
lda #$8d ; CR | |
jsr cout | |
lda addr0h | |
jsr prbyte | |
lda addr0l | |
jsr prbyte | |
lda #$ba ; ':' | |
jsr cout | |
; $ffba | |
dumpbyte: | |
lda #$a0 ; space | |
jsr cout | |
lda (addr0l,x) | |
jsr prbyte | |
; $ffc4 | |
; if the addresses are the same (or reversed), clear the state and go parse | |
; another address or command | |
dotmode: | |
stx state ; clear state | |
lda addr0l | |
cmp addr2l | |
lda addr0h | |
sbc addr2h | |
bcs gojump ; trampoline to parsech1 | |
; increment addr0 and loop back | |
inc addr0l | |
bne skip | |
inc addr0h | |
skip: | |
lda addr0l | |
and #7 ; setup so A=0 if the address now ends with 0 or 8 | |
bpl prnext | |
; $ffdc | |
; print A as 2 hex digits | |
prbyte: | |
pha | |
lsr | |
lsr | |
lsr | |
lsr ; high nybble first (shift into low nybble) | |
jsr prnybble | |
pla ; fall thru to print low nybble | |
; $ffe5 | |
; print the low nybble of A as '0'-'9' or 'A'-'F' | |
prnybble: | |
and #$0f | |
ora #$b0 ; '0' | |
cmp #$ba ; '9'+1 | |
bcc cout ; <='9' | |
adc #6 ; $BA+6+carry = $C1 'A' | |
; $ffef | |
; print the ascii char in A to the display | |
cout: | |
bit dsp | |
bmi cout ; wait for the display to be not-busy | |
sta dsp | |
rts | |
; $fff8 | |
; free space! 2 whole bytes! | |
brk | |
brk | |
; $fffa | |
; interrupt vectors | |
.data $0f00 ; NMI | |
.data start ; reset | |
.data $0000 ; IRQ |
Excellent work, thanks for sharing.
"and #7 ; setup so A=0 if the address now ends with 0 or 8"
This is always positive, so the "bpl prnext" is always executed.
@42Bastian Yes, bpl
is used as a branch-always instruction here. The reason for using this trick over a a plain jump is that it's a byte shorter (branch instructions use 1-byte relative offsets instead of 2-byte addresses).
So the bpl
should be considered a jump, and where the contents of the accumulator actually matter is under the prnext
label, where bne
is performed to insert a line feed every eight byte.
@RyuKojiro Despite your already having a transcribed copy of the source, it's still a cool feat robey performed, and he doubtless learned a lot. That in itself makes the effort worth while.
You know the original woz mon source is documented in the instruction manual for the Apple 1, right?
A transcribed copy exists in my Apple 1 emulator and there's a link to scans of the original instruction manual in the license file.