Friday, July 21, 2017

All it takes is one bad apple

A couple of years ago I came across a strange bug involving the small animal object. I had no idea how it worked when I first posted the video, but later I figured out that if you quickly scroll a collapsing ledge off screen right as it's starting to break apart, the next enemy you destroy has a good chance of spawning an animal frozen in mid-air.


Here's the relevant code from the crumbling platform object:
loc_205A6:
    move.b  $2A(a0),d0
    andi.b  #$18,d0
    beq.s   loc_205B6
    move.b  #1,$3A(a0)

loc_205B6:
    moveq   #0,d1
    move.b  7(a0),d1
    movea.l $3C(a0),a2
    move.w  $10(a0),d4
    jsr     (SolidObjectTopSloped2).l
    bra.w   Sprite_OnScreen_Test
Okay, so. At loc_205A6, we're testing the status of two bits in the object's status bitfield at $2A(a0). These bits are set depending on whether player 1 and/or player 2 are standing on the object. If either player is on the platform, $3A(a0) is set to 1, which signals the platform to be destroyed and eventually results in the object switching over to this code:
    subq.b  #1,$38(a0)
    bne.s   locret_2061E
    move.l  #loc_20620,(a0)
    lea     (Player_1).w,a1
    moveq   #3,d6
    bsr.s   sub_205FC
    lea     (Player_2).w,a1
    moveq   #4,d6

sub_205FC:
    btst    d6,$2A(a0)
    beq.s   locret_2061E
    bclr    d6,$2A(a0)
    bclr    #3,$2A(a1)
    bclr    #5,$2A(a1)
    bset    #1,$2A(a1)
    move.b  #1,$21(a1)

locret_2061E:
    rts
Once per frame, $38(a0) is decremented from a starting value of 7. When it reaches 0, the object calls the sub_205FC function twice, once for each player. This function checks whether a given player is currently on the platform (by testing the status bit specified in d6) and if so, forces them off. Since the function's body begins right after the code which calls it, the second bsr.s instruction is elided and the code falls directly into the function.

Note that in the case where $38(a0) isn't zero, we haven't actually run the code that makes the platform solid, as we do at loc_205B6. We can't do it at the end of this block due to the aforementioned fallthrough, but we have to do it before locret_2061E, and we can't do it between the decrement and the branch, since it would mess with the latter's result. So they ended up doing it in the absolute worst possible place:
loc_205A6:
    move.b  $2A(a0),d0
    andi.b  #$18,d0
    beq.s   sub_205B6
    move.b  #1,$3A(a0)

sub_205B6:
    moveq   #0,d1
    move.b  7(a0),d1
    movea.l $3C(a0),a2
    move.w  $10(a0),d4
    jsr     (SolidObjectTopSloped2).l
    bra.w   Sprite_OnScreen_Test
; ---------------------------------------------------------------------------
    ...

loc_205DE:
    bsr.w   sub_205B6
    subq.b  #1,$38(a0)
    bne.s   locret_2061E
    ...
Oh dear. There are now 8 frames in which if the platform is scrolled away, sub_205B6 will call Sprite_OnScreen_Test, which will then call Delete_Current_Sprite, which will set every byte on the object's SST to 0. And then, like some sort of shambling corpse, the object will execute the rest of loc_205DE, decrementing $38(a0) once before exiting.

We now have a free slot in which byte $38 is not zero, ready to screw over whatever object happens to spawn there.
    tst.b   $38(a0)
    bne.s   loc_2C9CA
    jsr     (Create_New_Sprite).l
    bne.s   loc_2C9C4
    move.l  #Obj_EnemyScore,(a1)
    move.w  $10(a0),$10(a1)
    move.w  $14(a0),$14(a1)
    move.w  $3E(a0),d0
    lsr.w   #1,d0
    move.b  d0,$22(a1)

loc_2C9C4:
    jmp     (Draw_Sprite).l
; ---------------------------------------------------------------------------

loc_2C9CA:
    move.b  #$1C,5(a0)
    clr.w   $18(a0)
    jmp     (Draw_Sprite).l
Through spectacular coincidence, the init code for the animal object checks the value of $38(a0), and if it isn't zero, the routine byte is set to $1C, and as can be seen in the video above, no score popup object is created.

But what is routine $1C? And under which circumstances is byte $38 set prior to the object's own initialization?
loc_2CB02:
    tst.b   4(a0)
    bpl.w   loc_2C9DA
    subq.w  #1,$36(a0)
    bne.w   loc_2CB1E
    move.b  #2,5(a0)
    move.w  #$80,8(a0)

loc_2CB1E:
    jmp     (Draw_Sprite).l
Turns out, routine $1C is actually leftover code from Sonic 2. In Sonic 2, animals inside end capsules are spawned with $38(a0) set to 1, and $36(a0) set in increments of 8. This causes them to all enter loc_2CB02 and begin decrementing $36(a0), and as they finish, they return to their normal routines and hop away one by one.

Unfortunately, our animal spawned with $36(a0) already set to 0, which after decrementing becomes $FFFF, or 65,535. In other words, it will wait 65,535 frames before hopping away. That's over 18 minutes. There's no saving him.

So please make sure you don't do anything after calling Delete_Current_Sprite. Think of the animals.

2 comments:

  1. I have made a video showcasing that, using debug mode, waiting about 18 minutes will eventually cause the animal to fly away.

    https://youtu.be/pf5fHMK0u34

    ReplyDelete