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.
image image

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.