Raiders of the Lost ROM part 3

We’ve been walking through the process of revitalizing an old 1990s era Lynx ROM called Quadromania. So far, most of the work has been focused on making existing features work. This time we’re going to try adding a brand new feature: stereo panning.

Panning is only supported on the Lynx II model. I’ve even read that some early Lynx II’s were shipped without stereo. Regardless, panning is a nifty feature, and it’s backwards compatible with Lynx I, so why not try it?

Panning adds a spatial effect to sounds. No, I don’t mean “outer space” effects like TIE fighters roaring through the apparently un-silent emptiness of space (which is still one my of favorite childhood movie memories, by the way). I mean adding a location component to a sound effect, so your ears and brain can correlate the source of a sound with a visual location on-screen.

Hunting around on the net, I found very little documentation on how stereo and panning works on the Lynx. Since documentation on Lynx panning is so sparse, yet I knew I got it working in Ponx 20 years ago, I did what any good hacker would do: I dusted off the old code and retaught myself how to do it. 🙂 I also found some register definitions that look useful but again with very little documentation. Hopefully I can pull it all together and shed some light on how it works.

Here are the registers we care about:

ATTENREG0 - ATTENREG3 $fd40-$fd43
MPAN    $fd44
MSTEREO $fd50

It turns out MSTEREO is interesting but not necessary, as this register is actually a stereo disable register. That means by default stereo is enabled but ignored on Lynx I, and enabled on Lynx II. Since there are four audio channels, the most-significant nybble controls left side channels 0-3, and the least-significant nybble controls right side channels 0-3. You set a bit to disable a channel. Why you would want to do this in an actual game, I’m not sure, so I will ignore it.

MPAN controls the panning feature, and does require MSTEREO to be enabled (meaning set to zero). MPAN and ATTENREG are both nonexistant on Lynx I, so you can safely modify these values but nothing happens on Lynx I. Similar to MSTEREO, but inverted in meaning, MPAN allows you to enable the corresponding ATTENREG by setting a bit. For example, if you want to enable panning on just channel zero for left and right stereo, you would set MPAN to $88. That could be interesting, if you knew you were dedicating channels to specific sounds that need panning. In my case, I’m just taking a brute force approach of either setting MPAN to $ff or to $00 — so it’s all or nothing for me.

ATTENREG attenuates (limits) the amount of output volume for the corresponding channel. Since we only have one byte per channel, that means one nybble for left audio and one nybble for right. Thus we have sixteen volume levels due to the 4-bit limit. So let’s say I want a sound effect I’m about to launch to have a “strong left” feel to it, because it’s triggered by an event near the left edge of the Lynx screen. And let’s say I know the sound is about to play in channel 1 (remember channels are numbered 0-3). Here’s what that would look like in assembly:

; Initialize panning values to default (max volume on all channels)
    lda	#$FF
    sta	ATTENREG0
    sta	ATTENREG1
    sta	ATTENREG2
    sta	ATTENREG3
    sta	MPAN

; ... do some game code ...

; Set panning for "strong left" on channel 1
    lda #$F8
    sta ATTENREG1

By picking value $f8, I’m telling the Lynx I want max volume of 15 on the left channel, but only volume 8 on the right. 8 would be a little over 50% volume. So I’ll hear the sound strong on the left side, but with a right component still present. If I wanted a “max left” sound, I would instead set it to $f0 which means no volume on the right side.

Klax uses panning to great effect, because blocks tumble towards the player in predefined rows. Thus the game can easily add the spatial component to the sounds. I likewise implemented panning on Ponx over 20 years ago to make it feel more immersive as the ball bounces from side-to-side. (NOTE: Seriously, if you haven’t played a Lynx game with panning enabled and earbuds or speakers hooked up, do it. Right now. You won’t regret it!) Quadromania is similar in that blocks are falling down the screen, with the added variable where the user is moving the blocks side-to-side. So we need to be careful how and when we update the panning regs, because the player’s x-coordinate will vary as the player touches the joypad. We don’t want to inadvertently modify any sounds already in flight.

Couple of ground rules to add this feature to Quadromania:

  1. Panning must only be applied to in-game sound f/x.
  2. New sound f/x which might overwrite an existing f/x should be panned according to the current falling block x-coordinate. In other words, just because the last sound was far left, use new panning settings if the player is in the middle or right side of the screen.

By inspecting the partial source, I found several invocations of a routine called do_sound (not to be confused with launch_sound, which plays the music). I also found some markers to identify the start of the main game loop. Next I used hackery as I explained in prior blogs to find the three instructions in the ROM which I could overwrite with jsr‘s to three new routines:

  • next_level_enable_panning – Every time the player starts a new game or new level, enable the panning feature. Code simply looks like this:
next_level_enable_panning
    ; Turn on panning with max volume on all channels
    lda #$FF
    sta ATTENREG0
    sta ATTENREG1
    sta ATTENREG2
    sta ATTENREG3
    sta MPAN

    ; Restore patched instruction and return
    lda random
    rts
  • end_level_disable_panning – Every time a level ends, including game end, disable the panning feature.
end_level_disable_panning
    ; Turn off panning
    stz	MPAN

    ; Restore patched instruction and return
    lda game_over_flag
    rts     
  • start_sfx_set_panning – Every time a sound f/x starts in-game, set the appropriate panning value. I won’t list out all the assembly code here, but instead I’ll summarize the flow.
start_sfx_set_panning 
    ; Inputs:
    ; X register - one-byte sound ID
    ; block_x - x-coordinate of player's falling block

    ; Quadromania grid is 10 units wide. For simplicity, I'm going to have
    ; five panning zones based on the x-coordinate of the falling block.

    ; Save A and Y regs on the stack (maybe not needed, but did it anyway)
    ; Set attenuation = 0

    ; Check if MPAN is zero
    ; TRUE: jump to end of routine
    ; FALSE: set attenuation values as shown below

    ; NOTE: attenuation variable is in the unused portion of the ZPAGE
    ; If block_x <= 1, then attenuation = $F8
    ; else if block_x <= 3, then attenuation = $DA
    ; else if block_x <= 5, then attenuation = $CC
    ; else if block_x <= 3, then attenuation = $AD
    ; else attenuation = $8F

    ; End of routine steps are below:

    ; Restore regs A & Y

    ; Restore patched instruction, which in this case is the call to the
    ; StartHSFX routine (return value in X is audio channel index)
    jsr	StartHSFX
    lda	attenuation
    sta	ATTENREG0,x	; Offset based on X reg. Setting ATTENREG is harmless on Lynx 1.

    ; return
    rts

So by setting a local variable that keys off the player’s current x-coordinate, then utilizing the audio channel value returned by StartHSFX, I can safely adjust the panning to make it sound like the current sound (which might be the dropping sound or an explosion) seem like it is coming from different parts of the playfield. Also note I’m intentionally strengthening the “side” of the sound f/x while also weakening the “off-side” of the effect. That’s why you see one nybble increasing while the other decreases in the above code.

I eventually tried this on a real Lynx unit with earbuds and it works!

Feature #4: SUCCESS

Next time we’ll talk about how to intercept joypad input, which is one of the most common tools I use for hacking a Lynx ROM. After all, he who controls the input controls the destiny of the game! And if I have time, I’ll also talk about a couple of key bugs I fixed in the code which led to the realization that the partial source I received did not exactly match the binary ROM.