Pobtastic / Music Player

Created Sat, 25 Apr 2026 20:34:17 +0100 Modified Sat, 25 Apr 2026 23:12:12 +0000

What Does It Do?

Samantha Fox Strip Poker plays background music during the strip reveal screens. The music was composed by Rob Hubbard, who chose two pieces for the game: The Entertainer by Scott Joplin, and The Stripper by David Rose.

The player is a two-channel beeper engine that drives the ZX Spectrum’s ULA speaker by toggling bit 4 of port $FE in a precision-timed inner loop. Both channels are mixed in software: each channel has its own half-period counter, and the two are interleaved in a single tight loop so that only one OUT instruction is needed to produce a composite waveform.

The music system lives entirely in high memory — $FA64 through $FFD0 — and is split into a small set of routines plus two sizable stream data blocks, one per channel.

Subroutine: Initialise Music

Before any gameplay screen is shown the music system is seeded by InitialiseMusic. All it does is write the starting read-pointer for each channel stream into the state variables the player uses at runtime.

; Initialise Music
;
; Called from #R$5B55 to set up the music system before gameplay
; begins.
@label=InitialiseMusic
c$FA64 LD HL,$FB6D   ; {Write Channel1_MusicStream to *Channel1_ReadPointer.
 $FA67 LD ($FA7F),HL ; }
 $FA6A LD HL,$FD9F   ; {Write Channel2_MusicStream to *Channel2_ReadPointer.
 $FA6D LD ($FA83),HL ; }

$FB6D is the start of the Channel 1 stream, and $FD9F is the start of the Channel 2 stream. The pointers written here will be advanced byte-by-byte as the player consumes notes.

Subroutine: Play Music

PlayMusic is the outer driver. It disables interrupts — critical for timing accuracy — then loops: calling the music step at $FAAB, then polling the ROM’s KEY_SCAN routine at $028E.

; Play Music
;
; Two-channel music player. Disables interrupts, then calls the
; music step at PlayMusic_Step in a tight loop. After each step,
; KEY_SCAN is polled; it returns #REGe = #N$FF when no key is pressed, so
; INC #REGe wraps to #N$00 and the loop continues. The routine
; exits only when a key is detected. Stream state is held in variables at
; Channel1_NoteCounter.
@label=PlayMusic
c$FA70 DI            ; Disable interrupts for accurate tone timing.
@label=PlayMusic_Loop
*$FA71 CALL $FAAB    ; Call PlayMusic_Step.
 $FA74 CALL $028E    ; Call KEY_SCAN.
; KEY_SCAN returns #REGe = #N$FF when no key is pressed; INC #REGe rolls
; #N$FF over to #N$00, setting the Z flag — so the loop continues until a key
; is detected.
 $FA77 INC E         ; {Jump back to PlayMusic_Loop unless a key (any) is pressed.
 $FA78 JR Z,$FA71    ; }
 $FA7A NOP           ; No operation.
 $FA7B RET           ; Return.

KEY_SCAN returns E=$FF when no key is pressed. INC E rolls $FF over to $00, setting the Z flag, and the loop continues. The moment a key is detected, KEY_SCAN returns a non-$FF value in E, INC E gives a non-zero result, Z is not set, and the routine falls through and returns.

So the music isn’t played a fixed number of times — it plays continuously and acts as the “wait for key press” gate for each reveal screen. Pressing any key stops the music and lets the game continue.

State Variables

Immediately following the outer loop code sits a block of state variables that the player reads and writes at runtime to track playback position.

; State variables: Channel1_NoteCounter-Channel2_NoteCounter hold the current
; note counters for channels 1 and 2; MusicBorderByte is the border colour
; toggle byte; Channel1_ReadPointer and Channel2_ReadPointer are the 16-bit
; stream read pointers; loop pointers at Channel1_LoopPointer and
; Channel2_LoopPointer; NoteDuration is the note duration.
@label=Channel1_NoteCounter
 $FA7C DEFB $02
@label=Channel2_NoteCounter
 $FA7D DEFB $16
@label=MusicBorderByte
 $FA7E DEFB $00
@label=Channel1_ReadPointer
 $FA7F DEFW $FCE6
@label=Channel1_LoopPointer
 $FA81 DEFW $FB6E
@label=Channel2_ReadPointer
 $FA83 DEFW $FF18
@label=Channel2_LoopPointer
 $FA85 DEFW $FDA0
@label=NoteDuration
 $FA87 DEFB $E6

The read pointers at Channel1_ReadPointer and Channel2_ReadPointer advance through the respective stream data each time a note is consumed. The loop pointers at Channel1_LoopPointer and Channel2_LoopPointer hold the address to restart from when an end-of-pattern marker is encountered — so each channel can loop independently.

Subroutine: Read Music Stream Byte

This small routine is the stream consumer. HL points at the two-byte read-pointer cell in the state block above; the routine loads the pointer into DE, advances it by one, fetches the byte, writes the updated pointer back, and returns the byte in A.

; Read Music Stream Byte
;
; Read the next byte from a music stream. #REGhl points at the
; two-byte stream pointer. Loads the pointer into #REGde, advances it
; by one, reads the byte at *(#REGde), then writes the updated pointer
; back and returns the byte in #REGa.
;
;   HL Music stream pointer storage
; O:A Note byte
@label=PlayMusic_ReadByte
c$FA88 LD E,(HL)     ; {Load the music stream pointer from (#REGhl) into
 $FA89 INC HL        ; #REGde.
 $FA8A LD D,(HL)     ; }
 $FA8B INC DE        ; Advance the music stream pointer by one.
; This entry point is used by PlayMusic_GetPeriod.
@label=PlayMusic_Fetch
*$FA8C LD A,(DE)     ; Load the music stream data into #REGa.
 $FA8D CP $40        ; {Jump to PlayMusic_NextPattern if the byte is the terminator
 $FA8F JR Z,$FAA3    ; marker (#N$40).}
 $FA91 LD (HL),D     ; {Write the updated pointer back to the storage.
 $FA92 DEC HL        ;
 $FA93 LD (HL),E     ; }
 $FA94 RET           ; Return.

If the byte at the current stream position is $40 — the end-of-pattern marker — the routine does not return immediately. Instead it falls into PlayMusic_NextPattern.

End of Pattern and Looping

When $40 is encountered, the two bytes that follow it are the 16-bit address of the loop-back point. The routine reads that address, restores HL to the read-pointer slot, and re-enters at PlayMusic_Fetch to read the first byte of the repeated section. This lets the streams loop seamlessly — and each channel can have its own loop point, so the two channels can be different lengths and still stay in phase across repeats.

; This entry point is used by PlayMusic_ReadByte.
@label=PlayMusic_NextPattern
*$FAA3 INC HL        ; Step #REGhl past the read pointer to the channel's
                     ; loop-back address field.
 $FAA4 LD E,(HL)     ; {Load the loop-back address into #REGde, rewinding the
 $FAA5 INC HL        ; stream to the start of the repeating section.
 $FAA6 LD D,(HL)     ; }
 $FAA7 DEC HL        ; {Restore #REGhl to the read pointer high-byte slot, ready
 $FAA8 DEC HL        ; for write-back by PlayMusic_Fetch.}
 $FAA9 JR $FA8C      ; Re-enter at PlayMusic_Fetch to fetch the first byte of the
                     ; repeated section.

Subroutine: Get Note Period

PlayMusic_GetPeriod converts a raw note byte into the half-period count that the tone generator uses. It adds $0C to the note byte to form a table index, then looks that index up in the period table at $FB38. The result comes back in H with L set to $01, and the index itself is left in E.

; Get Note Period
;
; Convert a raw note byte into a half-period count for the tone
; generator. The note byte is offset by #N$0C to index the period
; table at NotePeriodTable, where each entry is the half-period count
; passed to PlayMusic_Tone — higher values produce lower pitches. A
; period of #N$01 signals silence.
;
;   HL Note counter address
; O:H Period byte
; O:L Step flag (#N$01)
; O:E Note index (#REGe = note byte + #N$0C)
@label=PlayMusic_GetPeriod
c$FA95 LD A,(HL)     ; {Read the note byte from (#REGhl); add #N$0C to form the
 $FA96 ADD A,$0C     ; period table index.}
 $FA98 LD E,A        ; {Store the note index as a 16-bit table offset in
 $FA99 LD D,$00      ; #REGde.}
 $FA9B LD HL,$FB38   ; {Point #REGhl at this note's entry in the period table at
 $FA9E ADD HL,DE     ; NotePeriodTable.}
 $FA9F LD H,(HL)     ; Fetch the half-period count into #REGh; higher values
                     ; produce lower pitches.
 $FAA0 LD L,$01      ; Set #REGl = #N$01; PlayMusic_Step treats a period of
                     ; #N$01 as silence.
 $FAA2 RET           ; Return.

The Rest Note Trick

The most elegant detail in the whole player is hiding in plain sight. There are 53 entries in the period table, occupying addresses $FB38 through $FB6C. The note value $29 (41 decimal) produces an index of 41 + 12 = 53, which is one past the end of the table.

That out-of-bounds read lands at $FB6D — which is the first byte of the Channel 1 stream data block, a DEFB $01 placed there as an initial note counter. The byte happens to be $01, and a period of $01 signals silence.

No dedicated “rest” opcode, no branch, no special case — just a deliberate overflow into a known byte. $29 is the rest note.

Note Period Table

; Note Period Table
;
; Maps note bytes to half-period counts for the tone generator
; at PlayMusic_Tone. The note byte is offset by #N$0C to form the index;
; a higher count means more cycles between speaker toggles,
; producing a lower pitch.
@label=NotePeriodTable
b$FB38 DEFB $FF,$F0,$E3,$D7,$CB,$C0,$B4,$AB
 $FB40 DEFB $A1,$97,$90,$88,$80,$79,$72,$6C
 $FB48 DEFB $66,$60,$5B,$56,$51,$4C,$48,$44
 $FB50 DEFB $40,$3D,$39,$36,$33,$30,$2D,$2B
 $FB58 DEFB $28,$26,$24,$22,$20,$1E,$1C,$1B
 $FB60 DEFB $19,$18,$17,$15,$14,$13,$12,$11
 $FB68 DEFB $10,$0F,$0E,$0D,$0C

53 entries, covering a wide pitch range. The smallest value ($0C = 12) gives the shortest half-period and therefore the highest pitch; $FF (255) produces the lowest.

Subroutine: Music Step

PlayMusic_Step is called on each iteration of the outer loop. It reads the next note from each channel stream, resolves the half-period for each, and then either generates a tone or a silence gap.

; Music Step
;
; Read the next note from each channel stream, resolve their
; frequency periods, and dispatch to the tone generator at PlayMusic_Tone.
; If both channels report silence (period #N$01), falls through to
; the silence handler at PlayMusic_Silence.
;
; Fetch the next note byte for channel 1 from the stream at
; Channel1_ReadPointer.
@label=PlayMusic_Step
c$FAAB LD HL,$FA7F   ; {Point #REGhl at Channel1_ReadPointer; call
 $FAAE CALL $FA88    ; PlayMusic_ReadByte to read the next note byte into #REGa.}
 $FAB1 LD ($FA7C),A  ; Store the channel 1 note byte to Channel1_NoteCounter.
; Fetch the next note byte for channel 2 from the stream at
; Channel2_ReadPointer.
 $FAB4 LD HL,$FA83   ; {Point #REGhl at Channel2_ReadPointer; call
 $FAB7 CALL $FA88    ; PlayMusic_ReadByte to read the next note byte into #REGa.}
 $FABA LD ($FA7D),A  ; Store the channel 2 note byte to Channel2_NoteCounter.
; Resolve the half-period for channel 1.
 $FABD LD HL,$FA7C   ; {Point #REGhl at Channel1_NoteCounter; call
 $FAC0 CALL $FA95    ; PlayMusic_GetPeriod to resolve the half-period.}
 $FAC3 RL E          ; Rotate the note index left; carry set if note value
                     ; >= #N$74.
 $FAC5 JP C,$FB6E    ; Jump to Channel1_LoopPoint if the note index is out of range.
 $FAC8 PUSH HL       ; Save channel 1 period while resolving channel 2.
; Resolve the half-period for channel 2.
 $FAC9 LD HL,$FA7D   ; {Point #REGhl at Channel2_NoteCounter; call
 $FACC CALL $FA95    ; PlayMusic_GetPeriod to resolve the half-period.}
 $FACF POP DE        ; Recover channel 1 period into #REGde.
 $FAD0 LD A,H        ; {If channel 2 is not silent (#REGh != #N$01), dispatch
 $FAD1 DEC A         ; to PlayMusic_Tone.
 $FAD2 JR NZ,$FAD8   ; }
 $FAD4 LD A,D        ; {If channel 1 is also silent (#REGd = #N$01), fall to
 $FAD5 DEC A         ; PlayMusic_Silence.
 $FAD6 JR Z,$FB1A    ; }

If both resolved periods equal $01 — meaning both channels are resting this step — execution falls through to the silence handler. If at least one channel has an audible note, it dispatches to the tone generator. After the period lookups, D holds the channel 1 period and H holds the channel 2 period.

Subroutine: Play Tone

This is where the actual sound is produced. Both channels toggle bit $10 of the ULA output byte at independent half-period rates; the outer loop counts down the note duration. The border colour pulses with each speaker toggle as a visual side-effect.

Channel 1’s half-period is counted down in E (reloaded from IXh each time it elapses); channel 2’s is counted down in L (reloaded from H).

; Play Tone
;
; Drive two simultaneous tones through the Spectrum speaker
; port (#N$FE). Both channels toggle bit #N$04 of the output byte
; at independent half-period rates to produce two separate pitches;
; the outer loop counts down the note duration. The border colour
; pulses with each speaker toggle as a visual side-effect.
@label=PlayMusic_Tone
c$FAD8 LD A,($FA87)  ; {Load the note duration from NoteDuration into #REGc.
 $FADB LD C,A        ; }
 $FADC LD B,$00      ; Set #REGb = #N$00 for the DJNZ outer loop.
 $FADE LD A,($FA7E)  ; {Load the border byte from MusicBorderByte and stash the
 $FAE1 EX AF,AF'     ; primary audio phase in #REGaf'.}
 $FAE2 LD A,($FA7E)  ; Reload the border byte as the working audio phase.
 $FAE5 LD IXh,D      ; {Save the channel 1 half-period reload value to IXh
 $FAE7 LD D,$10      ; before overwriting #REGd with the speaker bit toggle mask (#N$10).}
@label=PlayMusic_ToneOuter
*$FAE9 NOP           ; {Two-NOP timing pad at the top of the outer loop.
 $FAEA NOP           ; }
@label=PlayMusic_ToneInner
*$FAEB EX AF,AF'     ; Toggle the speaker bit in #REGa.
 $FAEC DEC E         ; Decrement the channel 1 half-period counter.
 $FAED OUT ($FE),A   ; Output to the speaker port.
 $FAEF JR NZ,$FB08   ; Branch to PlayMusic_ToneBranch if channel 1 has not yet elapsed.
 $FAF1 LD E,IXh      ; {Channel 1 elapsed: reload its counter from IXh and
 $FAF3 XOR D         ; toggle the audio phase.}
 $FAF4 EX AF,AF'     ; Switch to the alternate audio phase.
 $FAF5 DEC L         ; {Branch to PlayMusic_ToneLow if channel 2 has not yet elapsed.
 $FAF6 JP NZ,$FB0F   ; }
@label=PlayMusic_ToneReload
*$FAF9 OUT ($FE),A   ; Channel 2 also elapsed: output to the speaker port.
 $FAFB LD L,H        ; {Reload the channel 2 counter from #REGh and toggle the
 $FAFC XOR D         ; audio phase.}
 $FAFD DJNZ $FAE9    ; Step the outer duration loop.
 $FAFF INC C         ; {Advance the duration counter and loop back to PlayMusic_ToneInner
 $FB00 JP NZ,$FAEB   ; until the note is complete.}
 $FB03 RET           ; Return.

The EX AF,AF' on every inner iteration alternates between two states of the accumulator, effectively toggling which speaker phase is being driven. XOR D (where D = $10) flips bit 4 at the appropriate half-period boundary for each channel. The two NOPs at the top of the outer loop are timing pads — their sole purpose is to keep the loop duration constant so that pitch frequencies stay accurate.

When channel 1’s counter elapses but channel 2’s hasn’t, execution continues from PlayMusic_ToneBranch. When channel 2’s elapses too, it jumps back to PlayMusic_ToneReload.

; Four unreachable bytes between the two code paths; the
; values #N$61 #N$64 #N$61 #N$6D read as ASCII "adam".
@label=PlayMusic_Adam
 $FB04 DEFM "adam"
; Channel 1 half-period has not yet elapsed.
@label=PlayMusic_ToneBranch
*$FB08 JR Z,$FB08    ; Channel 1 still counting: output to the speaker port.
 $FB0A EX AF,AF'     ; Switch to the alternate audio phase.
 $FB0B DEC L         ; {Jump to PlayMusic_ToneReload if channel 2 has also elapsed.
 $FB0C JP Z,$FAF9    ; }
@label=PlayMusic_ToneLow
*$FB0F OUT ($FE),A   ; Channel 2 still counting: output to the speaker port.
 $FB11 NOP           ; {Two-NOP timing pad.
 $FB12 NOP           ; }
 $FB13 DJNZ $FAE9    ; Step the outer duration loop.
 $FB15 INC C         ; {Advance the duration counter and loop back to PlayMusic_ToneInner
 $FB16 JP NZ,$FAEB   ; until the note is complete.}
 $FB19 RET           ; Return.

Tucked between the two return paths at $FB04 are four bytes that will never be executed — $61 $64 $61 $6D, which read as ASCII "adam". Whether that’s a name, an in-joke, or something else entirely, it’s a neat little signature to find hiding in the gap.

Subroutine: Music Silence

When both channels are resting, no OUT instructions are issued, but time still needs to pass to maintain the rhythm. PlayMusic_Silence busy-waits for exactly the same duration as an active note would take, without driving the speaker.

; Music Silence
;
; Rest handler: when both channels are silent, maintain the
; music rhythm by busy-waiting for exactly the same duration as an
; active note without driving the speaker.
@label=PlayMusic_Silence
c$FB1A LD A,($FA87)  ; {Load the note duration from NoteDuration, complement it, and
 $FB1D CPL           ; store in #REGc as the outer loop count.
 $FB1E LD C,A        ; }
 $FB1F PUSH BC       ; {Stash #REGbc and #REGaf on the stack.
 $FB20 PUSH AF       ; }
 $FB21 LD B,$00      ; Set #REGb = #N$00 so DJNZ runs #N$100 inner iterations.
@label=PlayMusic_SilenceLoop
*$FB23 PUSH HL       ; Stash #REGhl before using it as the timing pointer.
 $FB24 LD HL,$0000   ; Point #REGhl at ROM address #N$0000.
 $FB27 SRA (HL)      ; {Read and shift ROM address #N$0000 three times; the
 $FB29 SRA (HL)      ; writes are discarded but the reads consume the T-states needed
 $FB2B SRA (HL)      ; to match the tone generator timing.}
 $FB2D NOP           ; Timing NOP.
 $FB2E POP HL        ; Restore #REGhl from the stack.
 $FB2F DJNZ $FB23    ; Repeat the inner loop #N$100 times.
 $FB31 DEC C         ; {Decrement the outer loop counter and repeat from
 $FB32 JP NZ,$FB23   ; PlayMusic_SilenceLoop until the note duration is complete.}
 $FB35 POP AF        ; {Restore #REGaf and #REGbc from the stack.
 $FB36 POP BC        ; }
 $FB37 RET           ; Return.

The SRA (HL) instructions read ROM address $0000 three times each iteration. Writing to ROM is a no-op on the Spectrum — the writes are discarded — but the reads are real and consume precisely the T-states needed to match the timing of the tone generator loop. The tempo of rests stays in lock-step with sounded notes, keeping playback speed even.

The Music Streams

Each channel has its own data block. The stream format is simple: interleaved note and duration byte pairs. A note byte of $29 signals a rest; any other value is looked up in the period table to get the pitch. The duration byte controls how long the note is held. When the player encounters the end-of-pattern marker $40, the following two bytes are the 16-bit loop-back address — the player jumps there and continues reading from that point, so the music loops continuously while waiting for a key press.

The tune encoded in the streams here is The Entertainer by Scott Joplin, arranged by Rob Hubbard for the two-channel beeper. Channel 1’s stream ($FB6D$FD9E) carries the melody and opens with a single $01 byte, which is both the initial note counter and the deliberate overflow target for the $29 rest note as described above. The actual loop point is at Channel1_LoopPoint. Channel 2’s stream ($FD9F$FFD0) carries the accompaniment and loops from Channel2_LoopPoint.

---
title: "Subroutine: Music Step"
---
flowchart TD
    A["PlayMusic_Step"]-->B["Read channel 1 note from stream\n(PlayMusic_ReadByte)"]
    B-->C["Read channel 2 note from stream\n(PlayMusic_ReadByte)"]
    C-->D["Resolve channel 1 period\n(PlayMusic_GetPeriod)"]
    D-->E["Resolve channel 2 period\n(PlayMusic_GetPeriod)"]
    E-->F{"Both periods == 1?"}
    F--Yes-->G["PlayMusic_Silence\n(busy-wait, no audio)"]
    F--No-->H["PlayMusic_Tone\n(toggle OUT $FE in timed loop)"]
    G-->I[Return]
    H-->I