Skip to content

Instantly share code, notes, and snippets.

@robey
Last active May 22, 2023 03:49
Show Gist options
  • Star 66 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save robey/1bb6a99cd19e95c81979b1828ad70612 to your computer and use it in GitHub Desktop.
Save robey/1bb6a99cd19e95c81979b1828ad70612 to your computer and use it in GitHub Desktop.
apple 1 ROM disassembly
;
; 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
@ranobrega
Copy link

Excellent work, thanks for sharing.

@42Bastian
Copy link

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

@boomlinde
Copy link

boomlinde commented Sep 11, 2018

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

@sydbarrett74
Copy link

@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