"Sub Sprites" in Sonic 1

Discussion in 'Tutorials' started by Devon, Sep 8, 2018.

  1. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Introduction
    "What are these 'sub sprites' you speak of" you may ask. In Sonic 2 and 3K, if you set a bit in an object's "render flags", you were able to sacrifice a bit of the object's RAM to display multiple sprites in one object. With this, you could save quite a number of object slots.

    For example, in Sonic 1, each log in a bridge was an individual object, so it would potentially use up several object slots, depending on how long the bridge was. However, in Sonic 2, it would only use 1 or 2 objects and just draw multiple sprites for them.

    s1bridge.png s2bridge.png

    However, this is not a feature in Sonic 1. So, here, I will show you how you can implement such a feature. For this, I will primarily be using Sonic 2 as an example for simplicity's sake. With this, it would also open up the possibility of porting other objects from Sonic 2 and 3K to Sonic 1 without needing to make them use more object slots (such as the invincibility stars). You can also optimize certain objects, such as the swinging chained platforms or the Green Hill boss' swinging ball, by making the chains sub sprites (which is what Sonic 2 does).

    I will be using the Sonic 2 GitHub disassembly and the Sonic 1 GitHub disassembly (the sprite rendering routine (BuildSprites) in that isn't documented well as of September 8, 2018, so currently it's very similar to the 2005 Hivebrain version, even has the same labels).

    The purpose of this tutorial is to just port the sub sprite functionality to Sonic 1. There will be no changes to the mappings format and no other features or optimizations will be implemented.

    How Does It Work?
    In Sonic 2, if you set bit 6 of the render flags SST in an object, it'll enable sub sprites for the object. For that object, it will render a "main sprite" and then its sub sprites. To set it all up, you set the main sprite's size and frame ID, and then the number of sub sprites you want to render. Then, you can set the sub sprites' position and frame ID.

    These specific properties you set DO override some standard object SSTs. Specifically, the last byte in x_pos, the last 2 bytes in y_pos, x_vel , y_vel, intertia (for Sonic and Tails), y_radius, priority, anim_frame, anim, anim_frame_duration, collision_property, status, routine, and subtype. What is overridden really depends on the amount of sub sprites you have set to display (for example, only having 2 sub sprites will not override anim_frame_timer and onward).

    Take a look here:
    Code:
    mainspr_mapframe    = $B
    mainspr_width        = $E
    mainspr_childsprites     = $F    ; amount of child sprites
    mainspr_height        = $14
    sub2_x_pos        = $10    ;x_vel
    sub2_y_pos        = $12    ;y_vel
    sub2_mapframe        = $15
    sub3_x_pos        = $16    ;y_radius
    sub3_y_pos        = $18    ;priority
    sub3_mapframe        = $1B    ;anim_frame
    sub4_x_pos        = $1C    ;anim
    sub4_y_pos        = $1E    ;anim_frame_duration
    sub4_mapframe        = $21    ;collision_property
    sub5_x_pos        = $22    ;status
    sub5_y_pos        = $24    ;routine
    sub5_mapframe        = $27
    sub6_x_pos        = $28    ;subtype
    sub6_y_pos        = $2A
    sub6_mapframe        = $2D
    sub7_x_pos        = $2E
    sub7_y_pos        = $30
    sub7_mapframe        = $33
    sub8_x_pos        = $34
    sub8_y_pos        = $36
    sub8_mapframe        = $39
    sub9_x_pos        = $3A
    sub9_y_pos        = $3C
    sub9_mapframe        = $3F
    next_subspr       = $6
    
    One thing that Sonic 2 does to avoid issues with SSTs is to load an object whose purpose is only to hold these sub sprite properties and to display the sprites, while the main object handles all the programming and stuff, and when it needs to handle sprites, it sets the properties in the other object. Let's take a look at Obj15 in Sonic 2 for example. It starts with this:

    Code:
    Obj15:
        btst    #6,render_flags(a0)
        bne.w    +
        moveq    #0,d0
        move.b    routine(a0),d0
        move.w    Obj15_Index(pc,d0.w),d1
        jmp    Obj15_Index(pc,d1.w)
    ; ---------------------------------------------------------------------------
    +
        move.w    #$200,d0
        bra.w    DisplaySprite3
    It checks if the "sub sprite enable" flag is set and if so, make it so that it just runs DisplaySprite3, which is a routine that takes a precalculated offset for the object display queue instead of using the priority SST. Like I explained, the priority SST can be overridden by sub sprite data, so this routine exists, so it doesn't have to use the SST. However, if that flag is not set, it will then go to the normal Obj15 code, where upon initialization, it will load an Obj15 with that flag set.

    Depending on how you use your SSTs, you may not need to do something like that, but that is an option just in case.

    Implementation
    So, now let's go ahead an implement this, shall we? First thing you should do is define the sub sprite SSTs from above somewhere in your disassembly.

    Now, once you have the SSTs defined, let's get into the code. The magic really happens in "BuildSprites" (it's the same in both Sonic 1 and 2 disassemblies), so let's go there. It's in the main ASM file for all disassemblies. Do note that you'll want to change any RAM names or SST names to the ones defined in your disassembly (i.e. render_flags -> obRender for S2 GitHub to S1 GitHub).

    Also keep in mind that ASM68K does not support temporary labels (those "+" or "-" labels you may see occasionally in the Sonic 2 disassembly). You'll need to replace them with actual labels.


    Here is where we need to make our first change:
    Code:
            move.b    obRender(a0),d0
            move.b    d0,d4
            andi.w    #$C,d0
            beq.s    loc_D6DE
    This code grabs the render flags and does flag checks. In Sonic 2, this piece of code looks like this:
    Code:
        move.b    render_flags(a0),d0
        move.b    d0,d4
        btst    #6,d0    ; is the multi-draw flag set?
        bne.w    BuildSprites_MultiDraw    ; if it is, branch
        andi.w    #$C,d0    ; is this to be positioned by screen coordinates?
        beq.s    BuildSprites_ScreenSpaceObj    ; if it is, branch
    As you may see, Sonic 2 adds 2 additional lines between the ANDI instruction and the MOVE instruction. Those 2 lines of code check the "sub sprite enable" flag and branches to "BuildSprites_MultiDraw" if it is set. You'll want to add that in to Sonic 1's BuildSprites.

    In Sonic 2, "BuildSprites_MultiDraw" is located right after before the routine that copies mapping data to the sprite buffer. In Sonic 1, that routine is called "sub_D750", so right before that routine, create the "BuildSprites_MultiDraw" label.

    And now for the actual sub sprite rendering code. In Sonic 2, it starts off with:
    Code:
        move.l    a4,-(sp)
        lea    (Camera_X_pos).w,a4
        movea.w    art_tile(a0),a3
        movea.l    mappings(a0),a5
        moveq    #0,d0
    If you notice, it store the address of start of the main camera's position data in a4. However, if we take a look at how Sonic 1 handles grabbing the camera position data, we'll see this:
    Code:
    movea.l    BldSpr_ScrPos(pc,d0.w),a1
    Which is located directly after the code that grabs the render flags and checks it. What it's doing is choosing which camera in which the object will be drawn relative to. Which camera it picks depends on bits 2 and 3 in the render flags, which is exactly what that piece of code from before was doing (with both bits cleared just making it branch to loc_D6DE, meaning it should not be relative to any camera).

    The main issue here is that in Sonic 2, if an object is set to handle sub sprites, it is forced to be drawn relative to a camera, as it does not check those 2 bits in the render flags. For the sake of simplicity and staying true to the original functionality in Sonic 2, we will not handle multiple camera rendering for sub sprites.

    Next up, we have this bit of code. Go ahead and implement this.
    Code:
        ; check if object is within X bounds
        move.b    mainspr_width(a0),d0    ; load pixel width
        move.w    x_pos(a0),d3
        sub.w    (a4),d3
        move.w    d3,d1
        add.w    d0,d1
        bmi.w    BuildSprites_MultiDraw_NextObj
        move.w    d3,d1
        sub.w    d0,d1
        cmpi.w    #320,d1
        bge.w    BuildSprites_MultiDraw_NextObj
        addi.w    #128,d3
    Here, it checks the main sprite's X position to see if it's on screen horizontally. You may notice it's very similar to this in Sonic 1:
    Code:
            move.b    obActWid(a0),d0
            move.w    obX(a0),d3
            sub.w    (a1),d3
            move.w    d3,d1
            add.w    d0,d1
            bmi.w    loc_D726
            move.w    d3,d1
            sub.w    d0,d1
            cmpi.w    #$140,d1
            bge.s    loc_D726
            addi.w    #$80,d3
    The main difference really are the labels it branches to and the fact that it doesn't use the regular width SST ("obWidth"), but rather "mainspr_width".

    Next up is this. Go ahead and implement this.
    Code:
        ; check if object is within Y bounds
        btst    #4,d4
        beq.s    +
        moveq    #0,d0
        move.b    mainspr_height(a0),d0    ; load pixel height
        move.w    y_pos(a0),d2
        sub.w    4(a4),d2
        move.w    d2,d1
        add.w    d0,d1
        bmi.w    BuildSprites_MultiDraw_NextObj
        move.w    d2,d1
        sub.w    d0,d1
        cmpi.w    #224,d1
        bge.w    BuildSprites_MultiDraw_NextObj
        addi.w    #128,d2
        bra.s    ++
    +
        move.w    y_pos(a0),d2
        sub.w    4(a4),d2
        addi.w    #128,d2
        andi.w    #$7FF,d2
        cmpi.w    #-32+128,d2
        blo.s    BuildSprites_MultiDraw_NextObj
        cmpi.w    #32+128+224,d2
        bhs.s    BuildSprites_MultiDraw_NextObj
    +
    Basically like the previous set of code, but it checks the Y position. One main difference, however, is that it handles another flag from the render flags, bit 4. This bit, if clear, makes it so that it doesn't take into account the height of the sprite/object when checking if it's onscreen vertically or not. Again, you may notice it's similar to this piece of code from Sonic 1:
    Code:
            btst    #4,d4
            beq.s    loc_D6E8
            moveq    #0,d0
            move.b    obHeight(a0),d0
            move.w    obY(a0),d2
            sub.w    4(a1),d2
            move.w    d2,d1
            add.w    d0,d1
            bmi.s    loc_D726
            move.w    d2,d1
            sub.w    d0,d1
            cmpi.w    #$E0,d1
            bge.s    loc_D726
            addi.w    #$80,d2
            bra.s    loc_D700
    .............
    loc_D6E8:
            move.w    obY(a0),d2
            sub.w    obMap(a1),d2
            addi.w    #$80,d2
            cmpi.w    #$60,d2
            blo.s    loc_D726
            cmpi.w    #$180,d2
            bhs.s    loc_D726
    
    loc_D700:
    Pretty much the same deal as the previous set of code, different labels and the fact it uses "mainspr_height" instead of the normal height SST ("obHeight"). And if you notice, Sonic 1 does not have a "andi.w #$7FF,d2" instruction for this kind of code. If you really want to keep true to Sonic 1, go ahead and remove that instruction from the piece of code from Sonic 2.

    After that, it draws the main sprite with this piece of code:
    Code:
        moveq    #0,d1
        move.b    mainspr_mapframe(a0),d1    ; get current frame
        beq.s    +
        add.w    d1,d1
        movea.l    a5,a1
        adda.w    (a1,d1.w),a1
        move.w    (a1)+,d1
        subq.w    #1,d1
        bmi.s    +
        move.w    d4,-(sp)
        bsr.w    ChkDrawSprite    ; draw the sprite
        move.w    (sp)+,d4
    +
    Keep in mind that there really isn't a "ChkDrawSprite" in Sonic 1, and the fact that Sonic 1 handles sprite limits differently than Sonic 2. So, a change you'll want to make is to create a label in the sub_D750 routine between "movea.w obGfx(a0),a3" and "btst #0,d4", since in Sonic 2, if the sprite limit has not been reached, it would branch to to that "btst #0,d4". Once you have made the label, change the branch to "ChkDrawSprite" to that new label.

    Another change that we need to make to that piece of code is to change how it gets the number of sprites to draw. In Sonic 2, the number of sprite pieces in a frame is a word, but in Sonic 1, it's a byte, so you'll want to change the ".w" extension in "move.w (a1)+,d1" to a ".b" extension. To keep true to how Sonic 1 handles this kind of thing, apply the same change to "add.w d1,d1" and "subq.w #1,d1".

    For reference, take a look at this piece of code from Sonic 1:
    Code:
            move.b    obFrame(a0),d1
            add.b    d1,d1
            adda.w    (a1,d1.w),a1
            move.b    (a1)+,d1
            subq.b    #1,d1
            bmi.s    loc_D720
    After all that, we can finally render some sub sprites!

    So, it begins with:
    Code:
        ori.b    #$80,render_flags(a0)    ; set onscreen flag
        lea    sub2_x_pos(a0),a6
        moveq    #0,d0
        move.b    mainspr_childsprites(a0),d0    ; get child sprite count
        subq.w    #1,d0        ; if there are 0, go to next object
        bcs.s    BuildSprites_MultiDraw_NextObj
    Here, it sets the sprite as "on screen", like it would normally, and then it prepares to get the sub sprite data, and then gets the number of sub sprites to draw.

    Next up is this:
    Code:
    -    swap    d0
        move.w    (a6)+,d3    ; get X pos
        sub.w    (a4),d3
        addi.w    #128,d3
        move.w    (a6)+,d2    ; get Y pos
        sub.w    4(a4),d2
        addi.w    #128,d2
        andi.w    #$7FF,d2
        addq.w    #1,a6
        moveq    #0,d1
        move.b    (a6)+,d1    ; get mapping frame
        add.w    d1,d1
        movea.l    a5,a1
        adda.w    (a1,d1.w),a1
        move.w    (a1)+,d1
        subq.w    #1,d1
        bmi.s    +
        move.w    d4,-(sp)
        bsr.w    ChkDrawSprite
        move.w    (sp)+,d4
    +
        swap    d0
        dbf    d0,-    ; repeat for number of child sprites
    This goes through each available sub sprite, sets it up properly, and then draws it. You may notice the "ChkDrawSprite" there. Apply the same change we made to the previous set of code that had that. Also, here:
    Code:
        move.b    (a6)+,d1    ; get mapping frame
        add.w    d1,d1
        movea.l    a5,a1
        adda.w    (a1,d1.w),a1
        move.w    (a1)+,d1
        subq.w    #1,d1
        bmi.s    +
    This may looks familar. Apply the same changes as before here, too.

    And finally, it ends with this:
    Code:
    BuildSprites_MultiDraw_NextObj:
        movea.l    (sp)+,a4
        bra.w    BuildSprites_NextObj
    Which just goes back to the main sprite rendering loop. "BuildSprites_NextObj" is called "loc_D726" in Sonic 1.

    But, we are not quite finished yet! I did mention a "DisplaySprite3" before, did I not?

    In Sonic 2, it looks like this:
    Code:
    DisplaySprite3:
        lea    (Sprite_Table_Input).w,a1
        adda.w    d0,a1
        cmpi.w    #$7E,(a1)
        bhs.s    return_16542
        addq.w    #2,(a1)
        adda.w    (a1),a1
        move.w    a0,(a1)
    
    return_16542:
        rts
    Go ahead and implement this routine under the "DisplaySprite1" routine (located in "_incObj/sub DisplaySprite.asm") in the GitHub disassembly, or "DisplaySprite2" routine in the 2005 Hivebrain disassembly.

    And that should be it!

    Conclusion
    So, before we say we are all done and whatnot, you may want to test that you implemented this right. So, I have created a test object here. Here's the code:

    Code:
    ; ---------------------------------------------------------------------------
    ; Test object that tests out sub sprites
    ; ---------------------------------------------------------------------------
    
    ObjTest:
            btst    #6,1(a0)                ; Is this object set to render sub sprites?
            bne.s    OT_SubSprs                ; If so, branch
            moveq    #0,d0
            move.b    $24(a0),d0                ; Go to current object routine
            move.w    OT_Routines(pc,d0.w),d0
            jmp    OT_Routines(pc,d0.w)
    
    OT_SubSprs:
            move.w    #$200,d0                ; Display sprites
            jmp    DisplaySprite3
    
    ; ---------------------------------------------------------------------------
    
    OT_Routines:
            dc.w    OT_Init-OT_Routines
            dc.w    OT_Main-OT_Routines
    
    ; ---------------------------------------------------------------------------
    ; Initialization
    ; ---------------------------------------------------------------------------
    
    OT_Init:
            addq.b    #2,$24(a0)                ; Set as initialized
            jsr    FindFreeObj                ; Find a free object slot
            bne.s    OT_NoFreeObj
            move.w    a1,$3E(a0)                ; Set as child object
            move.b    #[ID],(a1)                    ; Load test object
            move.b    #%01000100,1(a1)            ; Set to render sub sprites
            move.w    #$780,2(a1)                ; Base tile ID
            move.l    #Map_Sonic,4(a1)            ; Mappings
            move.b    #$30,mainspr_width(a1)            ; Set main sprite width
            move.b    #$30,mainspr_height(a1)            ; Set main sprite height
            move.b    #4,mainspr_childsprites(a1)        ; Set number of child sprites
            move.w    8(a0),8(a1)                ; Set position
            move.w    $C(a0),$C(a1)
    
    OT_NoFreeObj:
    
    ; ---------------------------------------------------------------------------
    ; Main
    ; ---------------------------------------------------------------------------
    
    OT_Main:
            movea.w    $3E(a0),a1                ; Get child object
    
            moveq    #0,d6
            move.b    ($FFFFD01A).w,d6            ; Get frame to use
            move.b    d6,mainspr_mapframe(a1)            ; Set main sprite frame
    
            moveq    #0,d5
            move.b    mainspr_childsprites(a1),d5        ; Get number of sub sprites
            subq.b    #1,d5
            bmi.s    OT_NoSubSprs                ; If there are none, branch
            lea    sub2_x_pos(a1),a2            ; Get sub sprite data
    
    OT_SetSubSprs:
            move.b    $26(a0),d0                ; Get sine and cosine of the current angle
            jsr    CalcSine
            asr.w    #3,d0                    ; Get Y position
            add.w    $C(a0),d0
            asr.w    #3,d1                    ; Get X position
            add.w    8(a0),d1
            move.w    d1,(a2)+                ; Set X position
            move.w    d0,(a2)+                ; Set Y position
            move.w    d6,(a2)+                ; Set map frame
            addi.b    #$40,$26(a0)                ; Next angle to use
            dbf    d5,OT_SetSubSprs            ; Loop until every sub sprite is set
    
    OT_NoSubSprs:
            addq.b    #1,$26(a0)                ; Increment angle
            rts
    In the 2005 Hivebrain disassembly, "FindFreeObj" is "SingleObjLoad" and change "[ID]" to whatever object ID you set this object to.

    Go ahead and put it in your disassembly and then spawn it in a level. You should see something like this:


    If you do, great!, You are all set! If not, go back and see what you need to fix. Any "branch out of range" errors or "illegal value" errors you may get are easy to fix and solutions for them are on this very site somewhere.

    Enjoy your sub sprites.
     
    Last edited: Sep 10, 2018
  2. redhotsonic

    redhotsonic Also known as RHS Member

    Joined:
    Aug 10, 2007
    Messages:
    2,969
    Location:
    England
    Woah, I never knew Sonic 1 worked like this (then again, I don't hack Sonic 1... or anything these days). But it's always good to see a new form of optimisation into older titles. Neat and tidy guide by the way. Good work!
     
    Kilo likes this.
  3. MegaT

    MegaT Newcomer Member

    Joined:
    Aug 25, 2017
    Messages:
    10
    Location:
    I don't know....
    I'm kinda a noob at this but, What's a SST?
     
  4. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    It's just an object variable/property (i.e. object position, sprite mappings address, etc.) I believe it stood for "sprite status table offset" (with "sprite" actually referring to objects).
     
  5. Devon

    Devon Down you're going... down you're going... Member

    Joined:
    Aug 26, 2013
    Messages:
    1,372
    Location:
    your mom
    Okay, over a year later, it has been brought to my attention, that fucking bit 6 in the render flags SST is used to handle Sonic on loops in stock Sonic 1. I never went through a loop when testing this, so I never noticed.

    To fix that, find another flag in RAM to use for Sonic's loop flag.