Last active
May 22, 2023 03:49
-
-
Save robey/1bb6a99cd19e95c81979b1828ad70612 to your computer and use it in GitHub Desktop.
apple 1 ROM disassembly
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
; | |
; 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 |
@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.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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 theprnext
label, wherebne
is performed to insert a line feed every eight byte.