don't click here

Sonic 2 Source Code Quirks

Discussion in 'Engineering & Reverse Engineering' started by OrionNavattan, Jul 22, 2023.

  1. OrionNavattan

    OrionNavattan

    Tech Member
    172
    166
    43
    Oregon

    Might as well start a thread. In the process of combing over Sonic 2's code for my own ASM68K-based disassembly, I've noticed a handful of oddities and other things in the code, some of which hint at possibly unfinished/unimplemented features, and one that seems to hint at how development duties were split up. A couple are mentioned in the comments of the GitHub AS disassembly, but several of these are things I don't think have been discussed anywhere. More may be added later on. (When mentioning routines by name, I give the Sonic 2 Git name first, then the name I've given them in Sonic 2 ASM68K).

    Edit: This is incorrect, see MoDule's reply below.

    Flasher and the unused objpos bit:
    Converting the object position data to be assembled via Hivebrain's objpos macro rather than incbin'ed revealed this intriguing quirk with the Flasher. In Sonic 2's object position format, bits 5-7 of the third byte specify the respawn state, y-flip, and x-flip settings. Bit 4 is unused and always set to zero... at least, it should be. For some reason, every instance of the Flasher has this otherwise-unused bit set to 1. There is no code anywhere that does anything with this bit, and thus far I have not seen anything that would indicate what they could have been used for. My guess would have been for some sort of unimplemented 2P-mode functionality, but it's anyone's guess as to why.


    The subobject data system:
    The subobject data system is one of those things that makes little sense at first glance. Objects that use this system store their mappings pointer, vram/palette/priority data, render and priority settings, and collision flags in 12-byte long declarations, which are then loaded by a subroutine (LoadSubObject/LoadSubObjData) that is called by the object's code during its initialization. Other than saving space, I can't think of any good justification for this system; notably, objects that use it cannot use subtypes the way other objects do, as the subtype OST slot is used to store the index into a pointer list that LoadSubObject uses to fetch the OST data (more on this below). Technically this could be used to load different sets of OST data for an object, but I'm not sure if there are any objects that actually do this.

    It is noted in Sonic 2 AS that only Objects 8C and higher use this system, but I don't think anyone has mentioned that all of the objects that use this system appear to have been located in the same file in the original source code (specifically, everything from LoadSubObject to the end of EggRobo's code, indicated by all of the calls to LoadSubObject being located between the REV00/01 linker-generated JmpTos at those locations). Additionally, most of the object subroutines that immediately follow the subobject data index are used almost exclusively by objects in this file, the only exceptions being LoadChildObject/LoadChild, which is used by several of the ending cutscene objects, and Obj_GetOrientationToPlayer/GetClosestPlayer, ObjCapSpeed/CapSpeed, and ObjMoveStop/MoveStop, which are called by the Aquis badnik. I'm definitely not a code expert by any means, but I would guess that these routines, and all of those objects, were the work of a single programmer or team of programmers .


    Psuedo-subtypes - object IDs and y-flip bits:
    As mentioned above, objects that use the subobject data system cannot use subtypes in the normal manner. This seems to have led to workarounds for at least two objects, namely Grounder and Rexon. The Grounder object works around this by having two IDs assigned to it: the object ID itself is used as a subtype indicator. Some unused code in the Rexon object seems to suggest the same was planned for it as well.

    Code (ASM):
    1.  
    2. ; Sonic 2 AS
    3.     _move.b   id(a0),d0
    4.    subi.b   #ObjID_GrounderInWall,d0
    5.    beq.w   loc_36C64
    6.    move.b   #6,routine(a0)
    7.    rts
    8.  
    9.  
    10. ; ===========================================================================
    11. ; Sonic 2 ASM68K            
    12.        _move.b   ost_id(a0),d0               ; get ID
    13.        subi.b   #id_GrounderInWall,d0
    14.        beq.w   Ground_LoadWall               ; branch if this is a Grounder hiding in a wall
    15.        move.b   #id_Ground_StartRoam,ost_primary_routine(a0) ; if on ground, go to Ground_StartRoam next
    16.        rts  
    17.  
    18.  

    The Grounder has an additional quirk: every instance of this object in the object layout data has the y-flip flag set. Unlike the unused bit in the Flasher object, there is some code that processes the y-flip bit, which seems to suggest it would have been used to assign different priority levels to the object. The Grounder's subobject data actually has the priority bit set, but as is, said bit is always cleared.


    Code (ASM):
    1.  
    2. ; Sonic 2 AS
    3. Obj8D_Init:
    4.    bsr.w   LoadSubObject
    5.  
    6.    bclr   #1,render_flags(a0)
    7.    beq.s   +
    8.    bclr   #1,status(a0)
    9.    andi.w   #drawing_mask,art_tile(a0)
    10. +
    11. ; ===========================================================================
    12. ; Sonic 2 ASM68K
    13. Ground_Init:              
    14.        bsr.w   LoadSubObjData               ; go to Ground_Wait next
    15.  
    16.        bclr   #render_yflip_bit,ost_render(a0)   ; clear y-flip render bit
    17.        beq.s   .no_yflip               ; branch if it was already clear (it never is)
    18.        bclr   #status_yflip_bit,ost_primary_status(a0) ; clear y-flip status bit
    19.        andi.w   #tile_draw,ost_tile(a0)           ; clear priority bit
    20.  
    21.    .no_yflip:      
    22.  

    Shared Turtloid Rider return:
    One additional quirk related to the above observations: within the source file where all of the subobject data system objects are, a surprising number of return branches point to a single rts instruction located within the Turtloid Rider's code at $37A48. Infact, virtually every instance where a subroutine that does not end with an rts (i.e., ending with a jump to DisplaySprite) that needs to exit early branches to this one location instead of an rts that is closer. As for why, absolutely no clue.



    About all I have for the moment, but I'm sure there will be more as I dig further into its code.
     
    Last edited: Jul 22, 2023
    • Like Like x 2
    • Informative Informative x 1
    • List
  2. MoDule

    MoDule

    Tech Member
    327
    24
    18
    Procrastinating from writing bug-fix guides
    That's not entirely correct. Yes, you won't find any objects that themselves do anything with bit 4, but it has an important meaning with regards to how objects spawn. In the code below you'll see that the 2P objects manager checks for it and based on its state will load the object's data into a different part of object RAM:

    Code (ASM):
    1. ; ===========================================================================
    2. ;loc_17F80:
    3. ChkLoadObj_2P:
    4.     tst.b    2(a0)        ; does the object get a respawn table entry?
    5.     bpl.s    +        ; if not, branch
    6.     bset    #7,2(a2,d2.w)    ; mark object as loaded
    7.     beq.s    +        ; branch if it wasn't already loaded
    8.     addq.w    #6,a0    ; next object
    9.     moveq    #0,d0    ; let the objects manager know that it can keep going
    10.     rts
    11. ; ---------------------------------------------------------------------------
    12.  
    13. +
    14.     btst    #4,2(a0)    ; the bit that's being tested for here should always be zero,
    15.     beq.s    +        ; but assuming it weren't and this branch isn't taken,
    16.     bsr.w    SingleObjLoad    ; then this object would not be loaded into one of the 12
    17.     bne.s    return_17FD8    ; byte blocks after Dynamic_Object_RAM_2P_End and would most
    18.     bra.s    ChkLoadObj_2P_LoadData    ; likely end up somwhere before this in Dynamic_Object_RAM
    19. ; ---------------------------------------------------------------------------
    As for why this is done, to understand that you first need to know how the special two player mode objects manager works.

    Two player mode handles object spawning and despawning a little differently from the regular game. It splits a portion of object RAM into 6 blocks that each represent a vertical strip of the level two chunks wide, up to three per player, and accommodating twelve objects per block. Each block corresponds to the section of the level that the players occupy and the sections to their immediate left and right, with overlapping sections (i.e. when the players are close to each other) being shared. Just like with the regular objects manager, once a section of level is moved in range of the player, the objects within it are loaded into object RAM in bulk, although the sections in single player mode are only one chunk wide, thus object spawning is a little bit more granular. Where single player and two player differ is in how objects are despawned. Unlike in single player mode, where each object performs its own despawn check, in two player mode the objects manager itself performs a single check per occupied block. When the block is no linger within either player's range, its objects are deleted in bulk.

    This system of blocks has two consequences, one for object spawning and another for despawning:
    Since each two chunk wide section of the object layout corresponds to one of six blocks in object RAM, and only up to 12 objects can be loaded into a block, this limits how many objects can be placed per every two chunks horizontally. This is why CNZ has a stripped down object layout in two player mode.
    Despawning is no longer up to the individual objects. Although the routine to handle this is still called by every object, it just exits right away:

    Code (ASM):
    1. ; ===========================================================================
    2. ; input: a0 = the object
    3. ; loc_163D2:
    4. MarkObjGone:
    5.     tst.w    (Two_player_mode).w    ; is it two player mode?
    6.     beq.s    +            ; if not, branch
    7.     bra.w    DisplaySprite
    8. +
    This is likely an optimization to reduce despawning to a single calculation for multiple objects, but this also means that their actual positions are no longer considered. Most of the time this doesn't matter, as most objects don't move that far, let alone outside of the two-chunk wide section they were spawned in. However, if an object were to move beyond that, and if the player were to follow it far enough away from its spawn point, then once the section it originated in is out of range of the player, the object would be deleted, since only its initial position matters.

    For the handful of objects that need to be able to move beyond their spawn point, having bit 4 in their object layout entry set causes the objects manager to load them into a part of object RAM that is outside of the six blocks described above. Such objects are responsible for doing their own despawn checks and there's even a special routine for that which checks the objects position relative to both players' screens: MarkObjGone_P1

    And with this we can now explain why the Flasher badnik has bit 4 set. Follow it around for a bit and you'll see that it starts moving pretty far away from its origin point. If it didn't have bit 4 set, it could just pop out of existence if you follow it to some far-away point.

    Incidentally, are you sure it's only Flasher that uses this bit? It's been a while since I've looked at the original object layouts, but I thought I'd seen it on a few other objects.
     
    • Like Like x 3
    • Informative Informative x 1
    • List
  3. OrionNavattan

    OrionNavattan

    Tech Member
    172
    166
    43
    Oregon
    I haven't had a chance to study the 2P-mode object manager yet, hence why I completely missed all of this. Actually hadn't even thought of it. But hey, I was correct in assuming it was 2P-mode related.
    And I actually forgot there is one other object that uses this flag: EHZ's Buzzer. (I'd been planning this thread for a couple months and forgot some of the details in the interim.)