Lode Runner: Apple II reverse engineering

4. Sound#

4.1 Simple beep#

This simple beep routine clicks the speaker every 656 cycles. At approximately 980 nsec per cycle, this would be a period of about 0.64 milliseconds, or a tone of 1.56 kHz. This is a short beep, playing for a little over 0.1 seconds.

⟨beep [88]⟩=
    ORG     $86CE
BEEP:
    SUBROUTINE

    LDY     #$C0

.loop:
    ; From here to click is 651 cycles. Additional 5 cycles afterwards.
    LDX     #$80            ; 2 cycles

    ; delay 640 cycles
.loop2:
    DEX                     ; 2 cycles
    BNE     .loop2          ; 3 cycles

    LDA     ENABLE_SOUND    ; 3 cycles
    BEQ     .next           ; 3 cycles
    LDA     SPKR            ; 3 cycles

.next:
    DEY                     ; 2 cycles
    BNE     .loop           ; 3 cycles
    RTS
Defines BEEP

4.2 Sound "strings"#

A sound "string" describes a sound to play in terms of pitch and duration, ending in a 00. Just like in the PUT_STRING routine, rather than take an address pointing to a sound string, instead it uses the return address as the source for data. It then has to fix up the actual return address at the end to be just after the zero-terminating byte of the string.

Because NOTE_INDEX is not zeroed out, this actually appends to the sound data buffer.

The format of a sound string is duration, followed by pitch, although the pitch is lower for higher numbers.

One example of a sound string is 07 45 06 55 05 44 04 54 03 43 02 53, found in CHECK_FOR_GOLD_PICKED_UP_BY_PLAYER.

⟨defines [90]⟩+=
NOTE_INDEX      EQU     $54
SOUND_DURATION  EQU     $0E00       ; 128 bytes
SOUND_PITCH     EQU     $0E80       ; 128 bytes
Used in ⟨*⟩
Defines NOTE_INDEX, SOUND_DURATION, SOUND_PITCH
⟨load sound data [92]⟩=
    ORG     $87E1
LOAD_SOUND_DATA:
    SUBROUTINE

    PLA
    STA     SAVED_RET_ADDR
    PLA
    STA     SAVED_RET_ADDR+1
    BNE     .next

.loop:
    LDY     #$00
    LDA     (SAVED_RET_ADDR),Y
    BEQ     .end
    INC     NOTE_INDEX
    LDX     NOTE_INDEX
    STA     SOUND_DURATION,X
    INY
    LDA     (SAVED_RET_ADDR),Y
    STA     SOUND_PITCH,X

    INC     SAVED_RET_ADDR
    BNE     .next
    INC     SAVED_RET_ADDR+1

.next:
    INC     SAVED_RET_ADDR
    BNE     .loop
    INC     SAVED_RET_ADDR+1
    BNE     .loop

.end:
    LDA     SAVED_RET_ADDR+1
    PHA
    LDA     SAVED_RET_ADDR
    PHA
    RTS
Defines LOAD_SOUND_DATA

There's also a simple routine to append a single note to the sound buffer. The routine gets called with the pitch in A and the duration in X.

⟨append note [94]⟩=
    ORG     $87D5
APPEND_NOTE:
    SUBROUTINE

    INC     NOTE_INDEX
    LDY     NOTE_INDEX
    STA     SOUND_PITCH,Y
    TXA
    STA     SOUND_DURATION,Y
    RTS
Defines APPEND_NOTE

4.3 Playing notes#

The PLAY_NOTE routines plays a note through the built-in speaker. The time the note is played is based on X and Y forming a 16-bit counter (X being the most significant byte), but A controls the pitch, which is how often the speaker is clicked. The higher A, the lower the pitch.

The ENABLE_SOUND location can also disable playing the note, but the routine still takes as long as it would have.

⟨defines [96]⟩+=
ENABLE_SOUND    EQU     $99     ; If 0, do not click speaker.
SPKR            EQU     $C030   ; Access clicks the speaker.
Used in ⟨*⟩
Defines ENABLE_SOUND, SPKR
⟨play note [98]⟩=
    ORG     $87BA
PLAY_NOTE:
    SUBROUTINE

    STA     TMP_PTR
    STX     TMP_PTR+1

.loop:
    LDA     ENABLE_SOUND
    BEQ     .decrement_counter
    LDA     SPKR

.decrement_counter:
    DEY
    BNE     .counter_decremented
    DEC     TMP_PTR+1
    BEQ     .end

.counter_decremented:
    DEX
    BNE     .decrement_counter
    LDX     TMP_PTR
    JMP     .loop

.end:
    RTS
Defines PLAY_NOTE

4.4 Playing a sound#

The SOUND_DELAY routine delays an amount of time based on the X register. The total number of cycles is about 905 per each X. Since the Apple //e clock cycle was 980 nsec (on an NTSC system), this routine would delay approximately 887 microseconds times X. PAL systems were very slightly slower (by \(0.47\%\)), which corresponds to 883 microseconds times X.

⟨tables [100]⟩+=
    ORG     $86BE
SOUND_DELAY_AMOUNTS:
    HEX     02 04 06 08 0A 0C 0E 10 12 14 16 18 1A 1C 1E 20
Used in ⟨*⟩
Defines SOUND_DELAY_AMOUNTS
⟨sound delay [102]⟩=
    ORG     $86B1
SOUND_DELAY:
    SUBROUTINE

    LDA     SOUND_DELAY_AMOUNTS,X
    TAX

SOUND_DELAY1:
    LDY     #$B4         ; 180
.loop:
    DEY                  ; 2 cycles
    BNE     .loop        ; 3 cycles
    DEX                  ; 2 cycles
    BNE     SOUND_DELAY1 ; 3 cycles
    RTS
Defines SOUND_DELAY, SOUND_DELAY1

Finally, the PLAY_SOUND routine plays one section of the sound string stored in the SOUND_PITCH and SOUND_DURATION buffers. We have to break up the playing of the sound so that gameplay doesn't pause while playing the sound, although game play does pause while playing the note.

Alternatively, if there is no sound string, we can play the note stored in location \$A4 as long as location \$9B is zero. The duration is 2 + FRAME_PERIOD.

The routine is designed to delay approximately the same amount regardless of sound duration. The delay is controlled by FRAME_PERIOD. This value is hardcoded to 6 initially, but the game can be sped up, slowed down, or even paused.

⟨defines [104]⟩+=
FRAME_PERIOD    EQU     $8C     ; initially 6
Used in ⟨*⟩
Defines FRAME_PERIOD
⟨play sound [106]⟩=
    ORG     $8811
PLAY_SOUND:
    SUBROUTINE

    LDY     NOTE_INDEX
    BEQ     .no_more_notes
    LDA     SOUND_PITCH,Y
    LDX     SOUND_DURATION,Y
    JSR     PLAY_NOTE

    LDY     NOTE_INDEX              ; Y = NOTE_INDEX
    DEC     NOTE_INDEX              ; NOTE_INDEX--
    LDA     FRAME_PERIOD
    SEC
    SBC     SOUND_DURATION,Y        ; A = FRAME_PERIOD - SOUND_DURATION[Y]
    BEQ     .done
    BCC     .done                   ; If A <= 0, done.
    TAX
    JSR     SOUND_DELAY1

.done:
    SEC
    RTS

.no_more_notes:
    LDA     $9B
    BNE     .end
    LDA     $A4
    LSR                     ; pitch = $A4 >> 1
    INC     $A4             ; $A4++
    LDX     FRAME_PERIOD
    INX
    INX                     ; duration = FRAME_PERIOD + 2
    JSR     PLAY_NOTE

    CLC
    RTS

.end:
    LDX     FRAME_PERIOD
    JSR     SOUND_DELAY

    CLC
    RTS

Another routine is just for when a level is cleared. It appends a note based on a scratch location, and then plays it.

⟨append level cleared note [108]⟩=
    ORG     $622A
APPEND_LEVEL_CLEARED_NOTE:
    SUBROUTINE

    LDA     SCRATCH_5C
    ASL
    ASL
    ASL
    ASL                         ; pitch = SCRATCH_5C * 16
    LDX     #$06                ; duration
    JSR     APPEND_NOTE
    JMP     PLAY_SOUND
Defines APPEND_LEVEL_CLEARED_NOTE