In the previous article, we disassembled the sound ROM of the Defender arcade machine. In this article, we will examine how sounds are triggered from the game code, and look into the MC6800 assembly code, specifically focusing on the boot sound routine as an example.
In the first Defender article, we looked at the Defender hardware architecture. We mentioned that the main CPU (Motorola MC6809) runs the game code, and that the Defender Sound Board consisted of a Motorola MC6802 and a few other chips, running the audio code.
These two processors run in parallel. So how do they communicate? Well, when the game code running on the MC6809 CPU requests to play a sound on the sound board, it will set a special value determining which sound to play and then send an interrupt to the sound hardware. The interrupt will cause the MC6802 to immediately jump to a specific address in its program code and execute a bit of code that we call the interrupt handler, which will then start playing a new sound.
In detail, the steps of that process are:
The ROM is mapped to address $F800, and it's 2048 bytes long. So the ROM address space is $F800 - $FFFF.
The last 8 bytes at the end of the sound ROM contains the addresses of the interrupt handlers:
; address interrupt handlers FFF8 | dc.w $FCB6 ; Interrupt Request (IRQ) = FCB6 FFFA | dc.w $F801 ; Non-Maskable Interrupt (NMI) = F801 FFFC | dc.w $FD2F ; Software Interrupt FFFE | dc.w $F801 ; Reset handler = F801
So, when a sound is requested from the main MC6809 CPU, we get an interrupt request (IRQ), and the MC6802 CPU of the sound board jumps to $FCB6.
The interrupt handler at $FCB6 looks like this:
; FCB6: INTERRUPT HANDLER lds #$007F ; SP = 7f ldaa $0402 ; synth algo is in $0402 (PIA) ; C0 : boot sound (1100 0000) cli ; enable interrupts ... ; Here the handler uses the value from $0402 ; and some other state values to select an ; address to jump to. In the case of the boot ; sound, we end up down here at $FD06: ; FD06: PLAY BOOT SOUND suba #$1C ; a -= 1c (boot sound, a = 2) jsr $F82A ; play boot sound
Now we have seen a bit of how sounds are selected and triggered using interrupts. Next, we will look at how boot sound is implemented.
The boot sound is very unique and iconic, and it's not at all obvious how it is generated. All the more so because it consists of only 98 bytes of machine code:
It also accesses data in the ROM from addresse
$FD76 and 12 B forward. The 12 B of data are:
40 01 00 10 E1 00 80 FF FF 28 01 00
The disassembled code for the boot sound looks like this:
;------------------------------------------------------------------- ; DEFENDER BOOT SOUND ; Disassembled and annotated by The Nameless Algorithm 2015 ; ptr = $000F pw0_init = $0013 ; pw0 initial value pw1_init = $0014 ; pw1 initial value pw0_mod = $0015 ; pw0 frequency change pw1_mod = $0016 ; pw1 frequency change pw1_end = $0017 step_time = $0018 ; time until next frequency mod unused = $0019 param0 = $001A volume = $001B pw0 = $001C ; time of pulse down pw1 = $001D ; time of pulse up ; copy_values( int16 *source, int16 *target, int8 count ) ; x $000F b LF82A: ; boot sound, a = 2 tab ; b = a asla ; a *= 8 asla ; . asla ; . aba ; a = b+a (boot sound, a = 12) ldx #$0013 ; ptr = $0013 stx $000F ; ldx #$FD76 ; x = $FD76 ; init table jsr $FD21 ; x : RESULT = phasor( x: VALUE, a: FREQ ) ; - After calling phasor, x is either $FD21 or ; $FD22, based on the internal state of phasor. ; - Seemingly uses a data table with an oscillator ; that determines an offset of 0 or 1. ; - Possibly this functions as a way to randomly ; determine initialization data for SYNTH 1 (only ; two different initialization states, though) ; ldab #$09 ; b = 9 jmp $FB0A ; copy_values( x: SOURCE, ptr: TARGET, b: COUNT ) ; - this initializes the RAM variables used in ; SYNTH 1 from values stored in ROM ; - Why is this a jmp and not a jsr? PC is ; not stored on SP, so how does copy_values ; know where to return to? ; - The values are initialized to: ; pw0_init = $40 ; pw1_init = $01 ; pw0_mod = $00 ; pw1_mod = $10 ; pw1_end = $E1 ; step_time = $00 ; unused = $80 ; param0 = $FF ; volume = $FF ; LF83F: ldaa $001B ; DAC = volume (always $FF) staa $0400 ; . LF844: ; do ldaa $0013 ; pw0 = pw0_init staa $001C ; . ldaa $0014 ; pw1 = pw1_init staa $001D ; . LF84C: ; do ldx $0018 ; x = step_time * output pulse until LF84E: ; do x = 0 ldaa $001C ; a = pw0 com $0400 ; DAC invert * invert DAC LF853: ; do * pw0 busywait dex ; x -= 1 . beq LF866 ; if x = 0, goto skip . deca ; a -= 1 . bne LF853 ; while a != 0 . com $0400 ; DAC invert * invert DAC ldaa $001D ; a = pw1 LF85E: ; do * pw1 busywait dex ; x -= 1 . beq LF866 ; if x = 0, goto skip . deca ; a -= 1 . bne LF85E ; while a != 0 . bra LF84E ; while true * loops forever LF866: ; skip: ldaa $0400 ; a = DAC * a = DAC, invert a bmi LF86C ; if a < 0, goto noinv . if $FF ($FF is coma ; invert a .considered negative) LF86C: ; noinv: . adda #$00 ; . staa $0400 ; DAC = a . ldaa $001C ; pw0 += pw0_mod * increase pulse adda $0015 ; . . width starting staa $001C ; . . values (shorter ldaa $001D ; pw1 += pw1_mod . widths = higher adda $0016 ; . . frequency) staa $001D ; . . cmpa $0017 ; while pw1 != pw1_end * stop at specified bne LF84C ; . frequency ldaa $001A ; if param0 = 0, goto done ??? beq LF88B ; . adda $0013 ; pw0_init += param0 ??? staa $0013 ; . bne LF844 ; while pw0_init != 0 LF88B: ; done: rts ;------------------------------------------------------------------- ; copy_values( int16 *source, int16 *target, int8 count ) ; x $000F b ; ; Copies memory block of 'count' bytes from 'source' to 'target' in ; memory. ptr = $000D target = $000F copy_values: psha ; push a to stack LFB0B: ; do ldaa $00,x ; a = (*x) stx $000D ; ptr = x ldx $000F ; (*target++) = a staa $00,x ; . inx ; . stx $000F ; . ldx $000D ; x = ptr + 1 inx ; . decb ; while(--count > 0) bne LFB0B ; . pula ; pull a from stack rts ;------------------------------------------------------------------- ; result = phasor( int value, int8 freq ) ; x x a ; ; Updates a phase value with the specified frequency and ; increments value when phase rolls over ; static local var phase = $000E ; only used in this function in the entire ROM ; local var tmp = $000D LFD21: stx $000D ; tmp = value adda $000E ; phase += a staa $000E ; . bcc LFD2C ; if phase rolls over, value += 1 inc $000D ; tmp ++ LFD2C: ; done: ldx $000D ; result = tmp rts
It looks bigger in assembly code, doesn't it?
So what does this code output? The only values it outputs to the DAC ($0400) are the extreme 8-bit values, $FF and its complement, $00. If we alternate between $FF and $00, we get a square wave with a maximum DC offset, which is probably removed with analog circuitry after the DAC. However, the interesting part is in how the time between the two values is modified every cycle.
Or to use synthesis terms, the square wave pulse-width is constantly modulated.
The waveform isn't much to look at, but when viewed in the spectral domain, we see the complexity that is generated from this simple DSP code:
Let's remind ourselves that this is generated from 98 bytes of machine code. This is just one synth algorithm, and indeed, at least 10 different algorithms can be identified in the disassembled ROM.
Here is an overview over the 10 different sound algorithms and their locations in ROM:
Before I found DASMx, I tried using Sean Riddle's 6800dasm (mirrored here), but the output of DASMx was easier to convert to code compatible with vasm.