-
-
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.