Thursday, September 28, 2017

Rings are fundamentally broken in 2P mode

I know what you're thinking. "There are rings in Competition mode!?" Yes, starting from lap 4, a ring appears as an item on the item wheel, and on lap 5, there's even an enemy there which can cause you to take damage and drop it!

If you actually do so though, you'll find that the ring you drop is massively hosed. First off, it takes on the appearance of a garbled goal post. Then, even if player 2 happens to pick it up, the ring will go right back into player 1's item slot.


It's even worse when player 2 drops it. The ring doesn't disappear from player 2's item slot, and when someone picks it back up, it once again goes right into player 1's item slot, effectively duplicating it.


So what's going on? Well, several things, really. The bouncing ring object used in 2P mode is the same one used in 1P mode, which does not adequately account for a second player. As a change of pace, I thought it might be interesting to not only explain where things go wrong, but also to work out an adequate solution. Let's get to it!

When deciding how many ring objects to spawn, the ring-spawning object runs the following code fragment:
loc_1A68C:
    movea.l a0,a1
    moveq   #0,d5
    move.w  (Ring_count).w,d5
    tst.b   $3F(a0)
    beq.s   loc_1A69E
    move.w  (Ring_count_P2).w,d5
    ...
Apparently, when the ring-spawning object is initialized, byte $3F of its SST is set depending on which player took a hit, and the above code uses that to determine whose ring count should be considered. Surprisingly, a short while later, the same object uses this code to clear out the player's ring count:
loc_1A738:
    move.w  #$FFB9,d0
    jsr     (Play_Sound_2).l
    move.w  #0,(Ring_count).w
    ...
This explains why when player 2 takes damage, the ring doesn't disappear from their item slot. The game is attempting to remove it from player 1's item slot! Collecting the ring (thus duplicating it) and then taking damage again (say, on the underside of Chrome Gadget's moving platform) confirms this.

We can fix it by simply checking byte $3F again, as was done before:
Obj_2PRing_Done:
    move.w  #$FFB9,d0
    jsr     (Play_Sound_2).l
    tst.b   $3F(a0)
    beq.s   .notP2
    move.w  #0,(Ring_count_P2).w
    bra.s   Obj_2PRing_Main
; ---------------------------------------------------------------------------
.notP2:
    move.w  #0,(Ring_count).w
    ...
The reason dropped rings look like a garbled mess is because in Competition mode, the ring art is loaded to a different VRAM address. Furthermore, in all of the Competition levels, the ring colors are actually in palette line 3, since line 1 is already holding Knuckles' palette.

We can fix that by changing the line which sets the object's art_tile from this:
    move.w  #$A6BC,$A(a1)
to this:
    move.w  #$E3C6,art_tile(a1)
Then, as we muck around in the object's init code, we come across this line:
    move.b  #$47,$28(a1)
At offset $28 is the object's collision_flags attribute, and if we look at the collision type encoded in the top two bits, we find that it's set to 01, just as expected.

When the bouncing ring object is touched, it will be set to routine 4, which looks like this:
loc_1A7C2:
    addq.b  #2,5(a0)
    move.b  #0,$28(a0)
    move.w  #$80,8(a0)
    bsr.w   GiveRing
    ...
The GiveRing function gives player 1 a single ring, adding in extra logic to also award pending 1ups, and to make sure the counter doesn't overflow. Once again, the object is blindly affecting player 1's ring count, without taking into account who actually collected the ring. To fix this, we can change the object's collision type to one which distinguishes between players. Touch_Special seems to fit the bill.
    move.b  #$C7,collision_flags(a1)
Then, in the object's main routine we check (and clear) the collision_property attribute via the canonical method. Note that we are now forced to add the invulnerability timer check on the object's side of things:
Obj_2PRing_Main:
    bclr    #1,collision_property(a0)
    beq.s   .notP2
    cmpi.b  #90,(Player_2+invulnerability_timer).w
    bhs.s   .notP2
    bsr.w   GiveRing_P2
    bra.s   .collect
; ---------------------------------------------------------------------------
.notP2:
    bclr    #0,collision_property(a0)
    beq.s   .keepbouncing
    cmpi.b  #90,(Player_1+invulnerability_timer).w
    bhs.s   .keepbouncing
    bsr.w   GiveRing

.collect:
    addq.b  #2,routine(a0)
    move.b  #0,collision_flags(a0)
    move.w  #$80,priority(a0)
    ...
Having done the necessary changes, we build the ROM, load it up on our emulator of choice, and verify that everything works properly now.


Also note that there is now a race condition: if both players touch the ring on the exact same frame, the above code will award the ring to player 2. Curious, I decided to check out how Sonic 2 handles this scenario. Here is the relevant code from Sonic 2's version of Touch_ChkValue:
    move.w  (MainCharacter+invulnerable_time).w,d0
    tst.w   (Two_player_mode).w
    beq.s   +
    move.w  invulnerable_time(a0),d0
+
    cmpi.w  #90,d0
    bhs.w   +
    move.b  #4,routine(a1)  ; set the object's routine counter
    move.w  a0,parent(a1)
+
    rts
WOW, that's a lot simpler than what I came up with. The RAM address of the player who touched the ring is copied into the "parent" attribute of the ring object's SST. Later, all the ring has to do is check the attribute to find out grabbed it:
CollectRing:
    tst.b   parent+1(a0)            ; did Tails collect the ring?
    bne.s   CollectRing_Tails       ; if yes, branch
    ...
What's interesting is how the race condition is still there, and happens to have the same exact outcome. When player 2 processes the collision response list, they will overwrite the "parent" attribute set by player 1, laying claim to the ring.

No comments:

Post a Comment