Subsprites - RetroKoH/S1Fixed GitHub Wiki
(Original guide by Devon)
Source: Sonic Retro thread
Original Commit: ce5167d
The purpose of this tutorial is to backport the sub-sprite (or 'child sprite') functionality to Sonic 1. Tutorials for converting actual objects to this new system will be covered separately.
What are 'sub-sprites'?
In Sonic 2 and 3K, if you set a specific bit in an object's "render flags" (obRender
), you're able to sacrifice part of the object's RAM to display multiple sprites in one object. With this system, you can save quite a lot of object slots, and reduce the amount of processing power required for certain objects.
One such example is the Green Hill Zone bridge (Obj11). In the original game, each log in this bridge is an individual object that registers its own collision AND renders its own sprite, meaning it uses an extra object slot for every log in the bridge, the number of which depending on how long the bridge is. However, in Sonic 2, it uses just 1 object for collision, and only 1 or 2 objects to draw multiple sub-sprites for the logs.
How does it work?
In Sonic 2, if you set bit 6 of the obRender 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 coordinates and frame ID. These very properties WILL override some standard object OSTs. What is actually overridden depends on the amount of sub-sprites you have set to display (for example, only having 2 sub-sprites will not override anything from anim_frame_timer onward). Here are the sub-sprite variables, along with the OSTs that get overrode (in accordance with S1Fixed's OST arrangement).
; Main sub-sprite data
mainspr_mapframe: equ $B ; last byte of obX (2nd byte of obScreenY)
mainspr_width: equ $E
mainspr_childsprites: equ $F ; amount of child sprites
mainspr_height: equ $14
subspr_data: equ $10
; Sub-sprite #2 data
sub2_x_pos: equ $10 ; obvelX
sub2_y_pos: equ $12 ; obvelY
sub2_mapframe: equ $15
; Sub-sprite #3 data
sub3_x_pos: equ $16 ; obHeight/obWidth
sub3_y_pos: equ $18 ; obPriority
sub3_mapframe: equ $1B ; obAniFrame
; Sub-sprite #4 data
sub4_x_pos: equ $1C ; obAnim/obPrevAni
sub4_y_pos: equ $1E ; obTimeFrame/obDelayAni
sub4_mapframe: equ $21 ; obColProp
; Sub-sprite #5 data
sub5_x_pos: equ $22 ; obStatus/obActWid
sub5_y_pos: equ $24 ; obRoutine/ob2ndRout
sub5_mapframe: equ $27 ; obShieldProp (if S3K Shields are enabled)
; Sub-sprite #6 data
sub6_x_pos: equ $28 ; obSubtype
sub6_y_pos: equ $2A
sub6_mapframe: equ $2D
; Sub-sprite #7 data
sub7_x_pos: equ $2E
sub7_y_pos: equ $30 ; obBossX
sub7_mapframe: equ $33
; Sub-sprite #8 data
sub8_x_pos: equ $34
sub8_y_pos: equ $36
sub8_mapframe: equ $39 ; last byte of obBossY
; Sub-sprite #9 data
sub9_x_pos: equ $3A
sub9_y_pos: equ $3C
sub9_mapframe: equ $3F ; last byte of obParent
Other overrides are dependent on the object itself. The overrides noted above are OST constants shared by numerous objects throughout the game.
One thing that Sonic 2 does to avoid issues with OSTs is to load a second object whose purpose is only to hold these sub-sprites' properties and display them accordingly, while the main object handles all the actual logic. When it needs to handle sub-sprites, it sets the properties in that second object. To illustrate this, let's take a look at Obj15
in Sonic 2 (via Sonic Retro's s2disasm). It starts with this:
Obj15:
btst #6,render_flags(a0) ; Is this object set to render sub-sprites?
bne.w + ; if yes, branch
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 ; set priority of sub-sprites in the display queue
bra.w DisplaySprite3 ; display sub-sprites
It checks if the "sub-sprites" render flag is set, and if so, makes it so that it only runs DisplaySprite3, which is a routine that takes a pre-set offset for the object display queue instead of using the priority OST. Because the priority byte(s) can be overridden by sub-sprite data, this routine exists solely to not require use of that OST. However, if that flag is not set, it will then go to the normal Obj15 code, where upon initialization, it will load another Obj15 with that flag set. This is how the main object creates the "child" objects that use sub-sprites.
Depending on how you arrange your OSTs, and how many sub-sprites you require, you may not need to take this approach. It really depends on the needs of the object in question.
How do I implement this?
The first thing you need to do is define the sub-sprite OST constants from above somewhere in the Constants.asm file in your disassembly. Once that is done, we need to make the necessary modifications to the BuildSprites routine. In S1Fixed, this is located in _inc/BuildSprites.asm, but in stock Sonic 1, this is found in sonic.asm. In any event, find the routine and look for .objectLoop
. Edit this accordingly:
.objectLoop:
movea.w (a4,d6.w),a0 ; load object ID
tst.b (a0) ; if null, branch
beq.w .skipObject
bclr #7,obRender(a0) ; set as not visible
move.b obRender(a0),d0
move.b d0,d4
+ ; Devon Subsprites
+ btst #6,d0 ; is the multi-draw flag set?
+ bne.w BuildSprites_MultiDraw ; if it is, branch
+ ; Devon Subsprites End
andi.w #$C,d0 ; is this to be positioned by screen coordinates?
beq.s .screenCoords ; if yes, branch
movea.l BldSpr_ScrPos(pc,d0.w),a1
At the end of this function, where you find the comment ; End of function BuildSprites
, we are going to add the new BuildSprites_MultiDraw. Here it is:
; Devon Subsprites
BuildSprites_MultiDraw:
movea.w obGfx(a0),a3
movea.l obMap(a0),a5
moveq #0,d0
; check if object is within X bounds
move.b mainspr_width(a0),d0 ; load pixel width
move.w obX(a0),d3
sub.w (v_screenposx).w,d3
move.w d3,d1
add.w d0,d1 ; is the object's right edge to the left of the screen?
bmi.w BuildSprites_MultiDraw_NextObj ; if yes, branch
move.w d3,d1
sub.w d0,d1
cmpi.w #320,d1 ; is the object's left edge to the right of the screen?
bge.w BuildSprites_MultiDraw_NextObj ; if yes, branch
addi.w #128,d3
; check if object is within Y bounds
btst #4,d4 ; is the accurate Y check flag set?
beq.s BuildSpritesMulti_ApproxYCheck ; if not, branch
moveq #0,d0
move.b mainspr_height(a0),d0 ; load pixel height
sub.w (v_screenposy).w,d2
move.w d2,d1
add.w d0,d1 ; is the object above the screen?
bmi.w BuildSprites_MultiDraw_NextObj ; if yes, branch
move.w d2,d1
sub.w d0,d1
cmpi.w #224,d1 ; is the object below the screen?
bge.w BuildSprites_MultiDraw_NextObj ; if yes, branch
addi.w #128,d2
bra.s BuildSpritesMulti_DrawSprite
BuildSpritesMulti_ApproxYCheck:
; this doesn't take into account the height of the sprite/object when checking
; if it's onscreen vertically or not.
move.w obY(a0),d2
sub.w (v_screenposy).w,d2
addi.w #128,d2
andi.w #$7FF,d2 ; Could remove to remain faithful to Sonic 1
cmpi.w #-32+128,d2
blo.s BuildSprites_MultiDraw_NextObj
cmpi.w #32+128+224,d2
bhs.s BuildSprites_MultiDraw_NextObj
BuildSpritesMulti_DrawSprite:
moveq #0,d1
move.b mainspr_mapframe(a0),d1 ; get current frame
beq.s .noparenttodraw
add.b d1,d1
movea.l a5,a1 ; a5 is obMap(a0), copy to a1
adda.w (a1,d1.w),a1
moveq #0,d1
move.b (a1)+,d1
subq.b #1,d1 ; get number of pieces
bmi.s .noparenttodraw ; if there are 0 pieces, branch
move.w d4,-(sp)
bsr.w ChkDrawSprite ; draw the sprite
move.w (sp)+,d4
.noparenttodraw:
ori.b #$80,obRender(a0) ; set onscreen flag
lea sub2_x_pos(a0),a6 ; address of first child sprite info
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
.drawchildloop:
swap d0
move.w (a6)+,d3 ; get X pos
sub.w (v_screenposx).w,d3 ; subtract the screen's x position
addi.w #128,d3
move.w (a6)+,d2 ; get Y pos
sub.w (v_screenposy).w,d2 ; subtract the screen's y position
addi.w #128,d2
andi.w #$7FF,d2
addq.w #1,a6
moveq #0,d1
move.b (a6)+,d1 ; get mapping frame
add.b d1,d1
movea.l a5,a1
adda.w (a1,d1.w),a1
moveq #0,d1
move.b (a1)+,d1
subq.b #1,d1 ; get number of pieces
bmi.s .nochildleft ; if there are 0 pieces, branch
move.w d4,-(sp)
bsr.s ChkDrawSprite
move.w (sp)+,d4
.nochildleft:
swap d0
dbf d0,.drawchildloop ; repeat for number of child sprites
; loc_16804:
BuildSprites_MultiDraw_NextObj:
bra.w BuildSprites.skipObject
; End of function BuildSprites_MultiDraw
You'll notice a branch to ChkDrawSprite near the end of this code. This is indeed a new label, but luckily it's not pointing to new code. We just need to place this label at some existing code. Just below the new MultiDraw code we just added, we can see BuildSpr_Draw. We can add the label like so:
BuildSpr_Draw:
movea.w obGfx(a0),a3
+ChkDrawSprite: ; New label -- Devon Subsprites
btst #0,d4
bne.s BuildSpr_FlipX
btst #1,d4
Next, we need to add the new DisplaySprite variant. Open _incObj/DisplaySprite.asm and add this at the end:
; ---------------------------------------------------------------------------
; Devon Subsprites
; ---------------------------------------------------------------------------
; ||||||||||||||| S U B R O U T I N E |||||||||||||||||||||||||||||||||||||||
DisplaySprite2:
lea (v_spritequeue).w,a1
adda.w d0,a1
cmpi.w #$7E,(a1)
bhs.s DSpr2_Full
addq.w #2,(a1)
adda.w (a1),a1
move.w a0,(a1)
DSpr2_Full:
rts
; End of function DisplaySprite2
Now that we've got that done, let's implement a test object. Devon was kind enough to make one for us that renders multiple instances of Sonic's current frame with a formation and motion that resembles an Orbinaut badnik. Here is the code for that object, as it currently exists in S1Fixed:
; ---------------------------------------------------------------------------
; Object 10 - Test object that tests out sub sprites
; ---------------------------------------------------------------------------
Obj10:
btst #6,obRender(a0) ; Is this object set to render sub sprites?
bne.s OT_SubSprs ; If so, branch
moveq #0,d0
move.b obRoutine(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 DisplaySprite2
; ---------------------------------------------------------------------------
OT_Routines:
dc.w OT_Init-OT_Routines
dc.w OT_Main-OT_Routines
; ---------------------------------------------------------------------------
; Initialization
; ---------------------------------------------------------------------------
OT_Init:
addq.b #2,obRoutine(a0) ; Set as initialized
jsr (FindFreeObj).l ; Find a free object slot
bne.s OT_NoFreeObj
move.w a1,objoff_3E(a0) ; Set as child object
move.b obID(a0),obID(a1) ; Load test object
move.b #%01000100,obRender(a1) ; Set to render sub sprites -- %01000100 : Setting bit 6 enables subsprites for this object.
move.w #ArtTile_Sonic,obGfx(a1) ; Base tile ID
move.l #Map_Sonic,obMap(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 obX(a0),obX(a1) ; Set position
move.w obY(a0),obY(a1)
OT_NoFreeObj:
; ---------------------------------------------------------------------------
; Main
; ---------------------------------------------------------------------------
OT_Main:
movea.w objoff_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 obAngle(a0),d0 ; Get sine and cosine of the current angle
jsr (CalcSine).l
asr.w #3,d0 ; Get Y position
add.w obY(a0),d0
asr.w #3,d1 ; Get X position
add.w obX(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,obAngle(a0) ; Next angle to use
dbf d5,OT_SetSubSprs ; Loop until every sub sprite is set
OT_NoSubSprs:
addq.b #1,obAngle(a0) ; Increment angle
rts
Once you are done, open SonLvl and insert an object with ID $10 into the start of your first level, and boot up your game. You should see it right away. If you don't, go back and see what you need to fix.
Converting Objects to the new system
In the future, I'll be making specific guides for converting certain objects to this new system. Here's a list of the ones planned:
- GHZ Bridge (Obj11)
- Swinging Platform Objects in GHZ, MZ, SLZ, and SBZ2 (Obj15)
- Spiked Log Helix (Obj17)
- Invincibility Stars
- Eggman's Wrecking Ball (Obj48)
- LZ Spiked Ball & Chain (Obj57)
Each of these objects require multiple instances of the object to work together to render a multi-sprite construct. Condensing these down to anywhere from one to three objects not only saves object RAM, but greatly reduces the workload required to render the sprites required.