Fast AY-3-8912 Register Writes on the ZX Spectrum 128

Subliminal Extacy #07

Although there is no need to elaborate on the fact that the ZX Spectrum 128 incorporates the General Instrument AY-3-8912 sound generator, let's briefly recap how its internal registers are accessed. The chip provides sixteen register addresses (R0-R15), selected and written through two I/O ports: 65533 (#FFFD) and 49149 (#BFFD). Writing a value in the range 0..15 to port #FFFD selects the corresponding register, and a subsequent write to port #BFFD loads it with the new value.

Therefore, the conventional way of modifying a register looks something like this:

        ; D = register number, E = value

        ld   bc,#FFFD
        out  (c),d
        ld   b,#BF
        out  (c),e

When playing tracked music, a player usually prepares a sequence of register values in memory and writes them to the AY chip at regular intervals. It's not uncommon to find code similar to this in many interrupt handlers:

        ld   hl,AyRegs  ; Buffer of register values to output.
        ld   de,#FFBF
        ld   c,#FD
        xor  a
Loop    ld   b,d
        out  (c),a      ; Select the AY register.
        ld   b,e
        outi            ; Output the value at (HL).
        inc  a
        cp   13
        jr   nc,Loop

Suppose we wanted to optimize this routine for speed. One obvious approach would be to unroll the loop, leaving us with the following instruction sequence for each register:

4       ld   b,d
12      out  (c),a
4       ld   b,e
16      outi
4       inc  a
        ...

This amounts to 40 cycles per AY register. Can we do better? As it turns out, we can. Aligning the register buffer to a 256-byte boundary ensures that the low byte of the buffer's address is 0, allowing us to use L as the register index. Since OUTI automatically increments HL, the register index advances with each write eliminating the need for a separate increment:

4       ld   b,d
12      out  (c),l
4       ld   b,e
16      outi
        ...

The cost drops to just 36 cycles per register. The trade-off is reduced flexibility, as the buffer must be aligned to a 256-byte boundary and the registers must be output in a fixed order.

Although this approach might already be considered sufficient, let's push things even further, in the true demo-making spirit.

Consider that OUTI not only increments HL but also decrements B.

It seems a shame not to take advantage of this side effect. Turning #FF into #BF with a single decrement might seem impossible, until we consider how the ZX Spectrum actually decodes I/O addresses for the AY chip: only bits 15, 14, and 1 are taken into account. This means we don't need to transform %11111111 (#FF) into %10111111 (#BF) exactly; we only need to turn %11xxxxxx to %10xxxxxx. And that's straightforward if B initially contains %11000000, because the decrement performed by OUTI changes it to %10111111. The resulting value is suitable for selecting the AY data port, because the lower address bits are ignored.

Hardcore Z80 coders might already be raising an eyebrow at this point... doesn't OUTI decrement B before putting its value on the bus? Yes, absolutely, and the solution is simple: preload B with #C1 instead of #C0. That leaves us with the following loop:

        ld   hl,AyRegs
        ld   bc,#C1FD
        ld   a,b
        outi            ; Select the AY register from (HL).
        outi            ; Output the value at (HL).
        ld   b,a        ; Restore B to #C1.
        outi
        outi
        ld   b,a
        outi
        outi
        ld   b,a
        outi
        outi
        ...

So, what has changed? In terms of speed we're still at 36 cycles per register. However, the buffer no longer needs to be aligned to a 256-byte boundary, and we can now output registers in any order, thanks to the interleaved buffer format: reg 1, value 1, reg 2, value 2, reg 3, value 3, and so on. I find this approach quite elegant and philosophically satisfying.

Until next time!

baze / 3SC

Share your thoughts about the article