Monday, November 6, 2017

Unsafe cylinder DPLC, part 1

There's a strange bug associated with the vertical cylinders that cause the player to run around them in 3D, such as the mesh tubes in Flying Battery Zone. In a Sonic and Tails game, everything works fine when either character rides one of those objects, but when both ride it at the same time, Tails' movement becomes erratic, making him lag behind Sonic.


The mesh tube keeps track of which players are riding it by using the status bitfield at offset $2A of its SST. Specifically, using bits 3 and 4, just like the solid object collision functions. It does this by loading the RAM address of the player into register a1, the appropriate bit into register d6, and then calling a subroutine, just like the SolidObjectFull function:
loc_3A252:
    lea     (Player_1).w,a1
    lea     $30(a0),a2
    moveq   #3,d6
    bsr.s   sub_3A270
    lea     (Player_2).w,a1
    lea     $38(a0),a2
    addq.b  #1,d6
    bsr.s   sub_3A270
    jmp     (Delete_Sprite_If_Not_In_Range).l
The advantage of this technique is that the subroutine can become completely generic: all it has to do is check, set and clear the status bit pointed at by d6, all while affecting the player pointed at by a1.
    move.b  $22(a1),d0
    jmp     (Tails_Carry_LoadPLC).l
When a player is riding the object, the object overrides their usual DPLC requests by setting bit 1 of their object_control bitfield, and calling the misleadingly-named Tails_Carry_LoadPLC function. What this function actually does is jump to the correct DPLC function for the current character, based on the value of the player's character_id attribute:
Tails_Carry_LoadPLC:
    tst.b   $38(a1)
    beq.s   Sonic_Load_PLC2
    cmpi.b  #1,$38(a1)
    beq.w   Tails_Load_PLC2
    bra.w   Knuckles_Load_PLC2
The reason for the "2" labels is that they skip over the code which usually loads the player object's mapping frame from register a0. In our case, a0 is pointing at the mesh tube object, which is why we load it from register a1 in advance.
Sonic_Load_PLC:
    moveq   #0,d0
    move.b  $22(a0),d0

Sonic_Load_PLC2:
    cmp.b   ($FFFFF766).w,d0
    beq.s   locret_12D20
    move.b  d0,($FFFFF766).w
    lea     (PLC_Sonic).l,a2
    tst.b   (Super_Sonic_Knux_flag).w
    beq.s   loc_12CD6
    lea     (PLC_SuperSonic).l,a2
First thing the DPLC function does is compare the current mapping frame to the one previously stored to RAM address $F766; if these are the same, the function returns without doing anything. This is similar to the Perform_DPLC function, which only requests a DMA transfer when the mapping frame has changed.
loc_12CD6:
    add.w   d0,d0
    adda.w  (a2,d0.w),a2
    move.w  (a2)+,d5
    subq.w  #1,d5
    bmi.s   locret_12D20
    move.w  #$D000,d4
    move.l  #ArtUnc_Sonic,d6
After ensuring the DPLC script for the current mapping frame actually has any entries, the function begins getting ready to queue the DMA transfer. It loads Sonic's VRAM address, $D000, to register d4, and the ROM address for Sonic's art to register d6. Wait, d6? Let's go back to the code for the mesh tube:
loc_3A252:
    lea     (Player_1).w,a1
    lea     $30(a0),a2
    moveq   #3,d6
    bsr.s   sub_3A270
    lea     (Player_2).w,a1
    lea     $38(a0),a2
    addq.b  #1,d6
    bsr.s   sub_3A270
    jmp     (Delete_Sprite_If_Not_In_Range).l
Uh oh. After sub_3A270 is called the first time, the mesh tube object expects d6 to still be set to 3, which it then tries to increment to 4 before calling the subroutine again so it can handle the second player. However, if player 1 is also riding the object, then each time their mapping frame changes, the Tails_Carry_LoadPLC function will queue up a new DMA transfer, overwriting d6 with the address for Sonic's art in the process.
    include "Sound/Z80 Sound Driver.asm"
    align $8000
ArtUnc_Sonic:
    binclude "General/Sprites/Sonic/Art/Sonic.bin"
Thankfully, the art is aligned in a way that, whenever this happens, the low byte of d6 is set to zero. Once incremented, this becomes 1, which in the context of the status bitfield, corresponds to the vertical flip flag that is set when the object is first loaded from the object layout.

No upside down tubes exist in the layout, so this flag is always clear. As a result, player 2 is considered to not be riding the object, so the object stops handling their movement. However, since bit 4 is never touched, and since next time the object's code runs, player 1's mapping frame is unlikely to be changed again, d6 will be correctly incremented to 4, and player 2 will continue running around the tube like nothing ever happened.

There's a much more severe version of this bug lurking around the corner, though. I'll touch upon it in tomorrow's post.

No comments:

Post a Comment