Finally starting to look at the Arduino and AY-3-8910 was triggered by a couple of things recently. First getting an RC2014 and playing with AY-3-8910 based sound on that.
But also, having visiting RetroFest in Swindon this year, talking to Dean Belfield about the methods he used to develop for the ZX Spectrum and how he was donated a number of archive disks from the Follin brothers related to their producing music for 8-bit games.
The archive can be found here: https://github.com/breakintoprogram/archive-follin and it is a real treasure trove of information.
I wanted to try to understand some of it myself, so this is me poking about in the archives to learn a little about how some of this music was produced at the time.
After having a bit of an initial look and after posting a “this is interesting” post on Mastodon, Steven Tattersall told me about a similar thing they’d done with Follin’s ST driver too. This is for the YM2149 which is the Yamaha equivalent of the AY-3-8910. There is a great description of this one here: http://clarets.org/steve/projects/reverse_engineering_tim_follin.html
Once I’m a bit more through my own discussion it will be really interesting to compare the two.
For now, I’m just posting this based on my initial thoughts, and will update it as I go.
To be continued…
Warning! I strongly recommend using old or second hand equipment for your experiments. I am not responsible for any damage to expensive instruments!
If you are new to microcontrollers, see the Getting Started pages.
The Archives
From Dean’s introduction:
“The music and sound effects were hand-coded by Tim and Geoff Follin in assembler as a series of DEFB’s representing note pitch and duration for each channel. This data can also contains commands, for example to loop a sequence, call a subroutine or switch on an effect.”
“Once completed, their music source was included in the intended game along with a small music driver, also written in assembler.”
“This could then be called from within the game for both in-game music and sound effects”
The sound driver effectively implemented a series of instructions and turned these into commands to write out to the chip as part of its main “scan”.
There is an example of the sound driver here: https://github.com/breakintoprogram/archive-follin/blob/main/Examples/AY/Ghouls%20n%20Ghosts/aydrive.z80
And there is a set of instructions for the music here: https://github.com/breakintoprogram/archive-follin/blob/main/Examples/AY/Ghouls%20n%20Ghosts/g%2Bgmusic.z80
Combining these gives a block of assembly that can be built and run directly to play the sound.
Ste Ruddy’s Sound Driver
Ste Ruddy was interviewed about his work with Tim Follin and the sound driver he produced here: https://www.c64.com/interviews/ruddy.html.
“Working with Tim was essential when working on the music driver as basically he told me what he wanted the driver to do and I made it do it.”
As far as I can make out, the aydrive.z80 sound driver in the archive is in two main parts:
- A manager interface to load the track, run some kind of user-interface to show what is going on, and then call the sound routines.
- The sound driver itself.
In the assembly, the sound driver starts with the following:
;======================================
; AY MUSIC DRIVER V1.00 BY S.RUDDY
CODE_TOP:
There then follows a series of initialisation statements. These hold the internal state of the various aspects of the sound driver – things like current playing frequency, loop counts, volume levels, ADSR settings and so on. These are the items that implement the “language” that provides the instructions for what to play.
So what is that language? There is a list of commands (“OpCodes”) in the music file:
FOR: EQU 080H
NEXT: EQU 081H
LENGTH: EQU 082H
STOP: EQU 083H
GOSUB: EQU 084H
RETURN: EQU 085H
TRANS: EQU 086H
DISTORT: EQU 087H
SEND: EQU 088H
ADSR: EQU 089H
ENVON: EQU 08AH
WOBBLE: EQU 08BH
PORT: EQU 08CH
VIB: EQU 08DH
IGNORE: EQU 08EH
EFFECT: EQU 08FH
GOTO: EQU 090H
GATE_CON: EQU 091H
ENDIT: EQU 092H
The tune file contains sets of sound FX and tunes that consist of series of the above instructions to play notes and otherwise manipulate the AY-3-8910.
The basic code structure of the sound driver is as follows (using the assembler labels from aydrive.z80):
- Working values, variables, and parameters – “CODE_TOP”.
- Main tune driver setup – “TUNE”.
- Sound FX driver setup – “FX”.
- Main driver scanning routine – “REFRESH”.
- This takes the current state of all the variables, parameters, and so on, performs any updates as per the provided commands, and outputs the latest status to the AY-3-8910.
- This runs once “per frame”.
- There are three copies of this routine, processing in turn each of channel A, B and C.
- Main driver command routine.
- A “jump off” table for each OpCode command in the sound driver.
- Assembly routines for each command to update the variables, parameters, and process state of the sound driver.
- Again there are three copies of the jump table and routines, one for each of channel A, B and C.
- AY-3-8910 driver routine.
- Architecture specific code to access the chip.
- Note frequency table for 102 notes.
- Close-out code.
The entry points, as far as I can see, are as follows:
- Main tune driver setup “TUNE”.
- Sound FX setup “FX”.
- Scanning “REFRESH” routine.
The combined driver, tunes, and tester UI, when all combined, is structured as follows:
- UI / Tester.
- Sound Driver.
- Tune and FX instructions.
This is expanded upon in pseudo-code below using the labels from the original assembly:
Code_Start: EQU 40000
Data_Start: EQU 50000
;-----------------------------
ORG Code_Start
; The UI/tester code
TESTER:
LOOP: Calls the following for each scan:
HALT - Suspends until an interrupt comes in?
CALL UPDATE
CALL REFRESH
CALL CLOCK
CALL KEYSCAN
Repeat as necessary
KEYSCAN: UI scanning
CLOCK: Possibly maintain a 50Hz refresh rate clock?
UPDATE: Loads the internal state of all sound variables from
the driver and displays them in real time via the UI.
; The sound driver as described above
CODE_TOP:
TUNE: Select which tune to play.
TUNE_IN: Init all internal sound state variables for a new tune.
TUNEOFF: Stop a playing tune, eg to change tune or start an FX.
FX: Start playing an FX.
FLOOP: Keep processing FX instructions until complete.
REFRESH:
CHANNEL_A: IF STOP_A jump to CHANNEL_B
START_A: Process channel A state with CALLS to OUT_CA_AY or
OUT_FLESH as reqd and then RET back into this code.
Processing includes: ADSR, vibrato, distortion,
wobble, portamento, note frequency, etc.
DO_A: Entry point for getting next instruction.
COMMAND_A: Process a command for CH A using JUMP_A table.
GOTNOTE_A: Process a note for CH A with CALLS to OUT_CA_AY or
OUT_FLESH as reqd and RET back into this code.
Carry on to CH B
CHANNEL_B: IF STOP_B jump to CHANNEL_C
START_B: As above for Channel B
DO_B:
COMMAND_B:
GOTNOTE_B:
Carry on to CH C
CHANNEL_C: IF STOP_C RET to caller.
START_C: As above for Channel C
DO_C:
COMMAND_C:
GOTNOTE_C:
Finally JUMP to (not CALL) OUT_CA_AY so that the RET
is a RET to caller of the REFRESH code.
OUT_FLESH: Sets CH A,B,C and noise levels then jump to
OUT_CA_AY which includes the RET to caller.
JUMP_A: Jump table and associated instructions for CH A.
All instructions JUMP back to DO_A when complete.
JUMP_B: Jump table and associated instructions for CH B.
All instructions JUMP back to DO_B when complete.
JUMP_C: Jump table and associated instructions for CH C.
All instructions JUMP back to DO_c when complete.
OUT_CA_AY: Send contents of Z80 register C (0 to 255) to AY
register defined by Z80 register A (0 to 15).
RET to caller
NOTES: Frequency definitions for 102 notes.
CODE_BOT: End of Sound Driver
;-----------------------------
ORG Data_Start
; Tune and FX instructions
; 5 tunes and 21 effects
TUNES: EQU 5
EFFECTS: EQU 21
DATA_TOP:
TUNES_A: Jump table for 5 tunes info for CH A.
TUNES_B: Jump table for 5 tunes info for CH B.
TUNES_C: Jump table for 5 tunes info for CH C.
FX_TAB: Jump table for 21 effects info.
Instructions for 21 effects.
Instructions for 5 tunes.
So there is a main logic block – the “REFRESH” code, which defines the current frequency and volume level according to the state of the playing note, ADSR and any added effects. Then there are logic blocks for each of the recognised sound commands to adjust that state as appropriate.
The Sound OpCodes
I was going to document each in turn, but actually Steven Tattersall has already done a really good job of that here: http://clarets.org/steve/projects/reverse_engineering_tim_follin.html
I’ll expand on that as I go in terms of what it seems to mean for the Z80 driver, but for starting point that is a pretty good summary!
State Variables
Before we get into the main block of code, there are a number of state variables within the driver, which are essentially locations reserved in the assembly at the start, with a specific label. In most cases there are three of each, one for each of channel A, B, C.
These are a mixture of internal state variables and stored parameters from the various OpCodes.
There are four DW (word) declarations, all other are DB (byte) locations. I’ve re-ordered them here to put what I believe are related ones together.
- PC (WORD) – the “program counter” for recording the next instruction to process
- LOOP (WORD) – for loop “address”
- REPEAT – the loop count condition
- COUNT – the loop counter
- FREQ (WORD) –
- STOP – Stop paying this channel if > 0
- TRANS – Transpose in semitones
- LENGTH – Default note length
- IGNORE – ignore transpose for following note if > 0
- PORT – Portamento
- TARGET – for portamento
- DISTORT – raw “detune” number to add to playing frequency
- OLDFREQ
- VOLUME – channel volume
- FLIP1
- OLDNOTE – for portamento
- W_WAIT
- W_DEL1
- W_DEL2
- W_OFF
- A_INIT – ADSR parameters (4-bit)
- A_ATT – ADSR attack(4-bit)
- A_DEC – ADSR decay (4-bit)
- A_SUS – ADSR sustain level(4-bit)
- A_CYC – ADSR parameters (8-bit)
- A_STAGE – ADSR internal state
- A_TIME – ADSR internal state
- A_CONT – ADSR reset action
- V_DEL
- V_DEL1
- V_RATE
- V_LIM1
- V_LIM2
- V_DIR
- E_FREQ (WORD) – fixed frequency effect
- E_TIME – Fixed frequency effect
- E_WAIT – internal variable for effect
- E_BITS – mixer settings for effect
- FLESH
- ENDIT – turn mute off
- GATE
- GATERES – mute time?
- MEMGATE
I’ll walk through the main logic shortly, but before that, it is worth looking at the part of the code that processes the OpCodes:
LD BC,(PC_A) ; On entry, BC = PC_A
DO_A: LD A,(BC) ; Grab the next instruction
INC BC ; Inc the Program Counter
OR A ; If < 0x80 then it's a note
JP P,GOTNOTE_A
COMMAND_A: AND 127 ; Instruction &= 0x7F
ADD A ; x2
LD E,A
LD D,0 ; Now DE = Index into JUMP table
LD HL,JUMP_A ; HL = &JUMP_A
ADD HL,DE ; HL = &JUMP_A[Idx]
LD E,(HL) ; Load first byte into E
INC HL ; Move to 2nd byte to load into D
LD D,(HL) ; DE = OpCode address
EX DE,HL ; Swap DE/HL
JP (HL) ; Jump to OpCode code at addr in HL
GOTNOTE_A: ...
The instruction JUMP table comes later. Each entry is a WORD and is the address of the appropriate code to implement the OpCode, as follows:
JUMP_A: DW A_FOR
DW A_NEXT
DW A_LENGTH
DW A_STOP
DW A_GOSUB
DW A_RETURN
DW A_TRANS
DW A_DISTORT
DW A_SEND
DW A_ADSR
DW A_ENVON
DW A_WOBBLE
DW A_PORT
DW A_VIBRATO
DW A_IGNORE
DW A_EFFECT
DW A_GOTO
DW A_GATECON
DW A_ENDIT
On entry to each chunk of OpCode assembly, the registers appear to be:
- HL: Start address of the OpCode now running
- BC: Program/Stack Counter – now pointing to first location after OpCode
- DE: OpCode entry in the JUMP table (probably)
The descriptions in the following come from: http://clarets.org/steve/projects/reverse_engineering_tim_follin.html
FOR (repeats)
“0 – start_loop: Start a loop from this point. Loops are not stacked.”
A_FOR: LD A,(BC)
LD (REPEAT_A),A ; Store repeat counter in REPEAT_A
INC BC ; Loop repeats from next OpCode
LD (LOOP_A),BC ; Store loop location in LOOP_A
JP DO_A ; Next instruction
NEXT ()
“1 – end_loop: Decrement counter and if not zero, go back to loop point.”
A_NEXT: LD HL,REPEAT_A ; REPEAT_A--
DEC (HL)
JP Z,DO_A ; IF == 0 THEN next instruction
LD BC,(LOOP_A) ; ELSE return to LOOP_A
JP DO_A
LENGTH (value)
“2 – set_default_note_time: If not zero, all following notes take this value as the duration. If zero, all following notes have an extra byte with the note’s duration.”
A_LENGTH: LD A,(BC) ; Store LENGTH_A
LD (LENGTH_A),A
INC BC
JP DO_A ; Next instruction
STOP ()
“3 – stop: Stop playback of the channel.”
A_STOP: LD HL,STOP_A ; STOP_A++
INC (HL)
JP CHANNEL_B ; Move onto channel B
GOSUB (addr(WORD)) / RETURN ()
“4 – gosub: Push the current command address on a stack. Start processing from a new address. Next 2 bytes: offset of the subroutine from the start of the tune data (little-endian).”
“5 – return: Pop the return address off the stack and continue processing from the popped address.”
A_GOSUB: LD A,(BC) ; Set HL to required address
LD L,A
INC BC
LD A,(BC)
LD H,A
INC BC
PUSH HL ; Store address on real stack
LD HL,(SP_A) ; Grab OpCode SP (SP_A)
LD (HL),C
INC HL
LD (HL),B
INC HL ; Store OpCode PC in OpCode stack
LD (SP_A),HL ; Store updated OpCode SP
POP BC ; Grab address off real stack and...
JP DO_A ; ... run from there
A_RETURN: LD HL,(SP_A) ; Grab OpCode SP from SP_A
DEC HL ; Grab OpCode PC from OpCode stack
LD B,(HL) ; Stick it back into BC
DEC HL
LD C,(HL)
LD (SP_A),HL ; Update SP_A again
JP DO_A ; then continue processing again
SP_A: DW 0 ; OpCode stack pointer
STACK_A: DS 2*SD,0 ; OpCode stack (SD = 3)
TRANS (value)
“6 – set_transpose: Next byte: number of semitones to transpose all following notes. Signed 8-bit value.”
A_TRANS: LD A,(BC) ; Store value in TRANS_A
LD (TRANS_A),A
INC BC
JP DO_A ; Next instruction
DISTORT (value)
“7 – set_raw_detune: Next byte: raw value to add to the final note period in YM register space. Unsigned 8-bit value.”
A_DISTORT: LD A,(BC) ; Store value in DISTORT_A
LD (DISTORT_A),A
INC BC
JP DO_A ; Next instruction
SEND (reg, value)
“8 – direct_write: Write a value directly to the YM registers. This is often used to write noise pitch.
The “mixer” register, register 7, is treated differently. This register combines settings for all 3 channels A,B,C to determine whether they use the square or noise channel, so the driver ensures that only the bits relevant to the active channel are set and cleared.”
A_SEND: LD A,(BC)
LD L,A ; L = register to write
INC BC
LD A,(BC)
INC BC
PUSH BC ; Store OpCode PC for later
LD C,A ; C = value to write
LD A,L
CP 7 ; IF reg != 7 JP A_NOT_IO
JP NZ,A_NOT_IO
LD A,C
LD (FLESH_A),A
LD (MEMGATE_A),A
CALL OUT_FLESH ; OUT_FLESH handles R7 (mixer)
POP BC ; Grab OpCode PC back
JP DO_A ; Next instruction
A_NOT_IO: CALL OUT_CA_AY ; A=reg, C=value
POP BC ; Grab OpCode PC back
JP DO_A ; Next instruction
ADSR (INIT|SUS, ATT|DEC, CYC)
“9 – set_adsr: Sets the note envelope. This takes 3 bytes and contains the attack and decay speeds, the minimum and maximum volume levels after attack or decay, and which stage to start in (attack, decay, or hold).”
A_ADSR: LD A,(BC)
RRA
RRA
RRA
RRA
AND 15
LD (A_INIT_A),A ; A_INIT_A = param[0] >> 4
LD A,(BC)
AND 15
LD (A_SUS_A),A ; A_SUS_A = param[0] & 0x0F
INC BC
LD A,(BC)
RRA
RRA
RRA
RRA
AND 15
LD (A_ATT_A),A ; A_ATT_A = param[1] >> 4
LD A,(BC)
AND 15
LD (A_DEC_A),A ; A_DEC_A = param[1] & 0x0F
INC BC
LD A,(BC)
LD (A_CYC_A),A ; A_CYC_A = param[2]
INC BC
JP DO_A ; Next instruction
ENVON (value)
“10 – set_adsr_reset: Next byte: if zero, moving to a new note does not reset ADSR, otherwise ADSR is reset. (A zero value is usually used to define complex arpeggio sequences.)”
A_ENVON: LD A,(BC) ; Store value in A_CONT_A
LD (A_CONT_A),A
INC BC
JP DO_A ; Next instruction
WOBBLE (offset, del1, del2)
“11 – set_arpeggio: Sets the semitone note offset of the arpeggio and the times they are held for.”
A_WOBBLE: LD A,(BC)
LD (W_OFF_A),A ; W_OFF_A = param[0]
INC BC
LD A,(BC)
LD (W_DEL1_A),A ; W_DEL1_A = param[1]
INC BC
LD A,(BC)
LD (W_DEL2_A),A ; W_DEL2_A = param[2]
INC BC
JP DO_A ; Next instruction
PORT (value)
“12 – set_slide: Set the number of semitones to jump per update when applying glissando between notes. Usually set to 1.”
A_PORT: LD A,(BC)
LD (PORT_A),A ; PORT_A = value
INC BC
JP DO_A ; Next instruction
VIB (del, rate, lim, dir)
“13 – set_vibrato: Set the delay, size, speed and starting direction of the vibrato effect.”
A_VIBRATO: LD A,(BC)
LD (V_DEL_A),A ; V_DEL_A = param[0]
INC BC
LD A,(BC)
LD (V_RATE_A),A ; V_RATE_A = param[1]
INC BC
LD A,(BC)
LD (V_LIM2_A),A ; V_LIM2_A = param[2]
INC BC
LD A,(BC)
LD (V_DIR_A),A ; V_DIR_A = param[3]
INC BC
JP DO_A ; Next instruction
IGNORE()
“14 – skip_transpose: For the next note only, don’t apply transpose. Usually used for drums mixed in with bassline notes.”
A_IGNORE: LD A,255
LD (IGNORE_A),A ; IGNORE_A = 255 (on)
JP DO_A ; Next instruction
EFFECT (time, bits, freq)
“15 – set_fixfreq: Force using a fixed frequency, defined in the next 3 bytes (mixer and period low/high), or turn it off by using a single zero-byte.”
A_EFFECT: LD A,(BC)
LD (E_TIME_A),A ; E_TIME_A = param[0]
INC BC
; (skipping some commented out code)
A_SETEFF: LD A,(BC)
LD (E_BITS_A),A ; E_BITS_A = param[1]
INC BC
LD A,(BC)
LD (E_FREQ_A),A ; E_FREQ_A = param[2]
INC BC
LD A,(BC)
LD (E_FREQ_A+1),A ; E_FREQ_A = param[3]
INC BC
JP DO_A
GOTO (addr)
“16 – jump: Jump to a new offset. Used for the infinite looping of tunes.”
A_GOTO: LD A,(BC) ; L = addr[0]
LD L,A
INC BC
LD A,(BC) ; H = addr[1]
LD H,A
PUSH HL
POP BC ; Set BC = addr then
JP DO_A ; run the next instruction
GATECON (value)
“17 – set_mute_time: Turn the channel off after N more updates.”
A_GATECON: LD A,(BC)
LD (GATERES_A),A ; GATERES_A = value
INC BC
JP DO_A ; Next instruction
ENDIF (value)
“18 – set_nomute: If set to non-zero suppress the automatic muting. The starting commands of a channel usually set this to 0xff.”
A_ENDIT: LD A,(BC)
LD (ENDIT_A),A ; ENDIT_A = value
INC BC
JP DO_A ; Next instruction
The Main Logic Function
I’ll work through the assembly for the main REFRESH function in time. Watch this space!
Closing Thoughts
I’ll keep coming back to this and posting an update as I get into more of it. When I think I’m done, I’ll post a proper conclusion.
For now, its just interesting to note that even though I’m sure many people have been through this before me already, it is a really interesting activity to try to figure it out myself.
As I say – to be continued…
Kevin