Thursday, August 31, 2017

Act transitions, part 4: whither offset?

Something funny you might not realize about Mushroom Hill Zone's act 1 boss is that due to how the infinite scroll is set up, the boss is always cutting down the same tree, over and over.

Here's how it works. The camera locks at the area highlighted in pink, and the boss starts whittling down the tree on the right side of the screen. Once he's done, he dashes off to the right, and the camera lock gives way.

When the camera reaches the cyan area on the right, all objects are shifted 512 pixels to the left, placing the camera at the cyan area on the left. The boss then stops over the middle tree, and the camera locks at the pink area again.

In act 2, although the camera starts off at the same position which it locks at during the act 1 boss, the developers had the foresight to extend the level layout a bit further to the left, in order to encompass the entire looping section of act 1. This way, even if the boss is defeated before the camera locks back at the pink area, the player will never accidentally spawn outside of the act 2 layout.

However, nothing prevents you from defeating the boss after the camera unlocks, but before it can reach the cyan area on the right. When that happens, you start act 2 further along than intended, which looks more than a little funky.

If you're playing as either Sonic or Tails, this is a good time to put the controller down and go take a break. Because the camera starts off at the wrong position, the Knuckles cutscene doesn't activate, which means you're effectively stuck in the intro area until time runs out.

Wednesday, August 30, 2017

Act transitions, part 3: Icecap Zone

Icecap Zone's act transition also doesn't happen during the act 1 results. In fact, it holds the dubious honor of being the only transition which doesn't involve a screen lock: it takes place inside the long corridor right before the final star post.

While it was definitely a bold move, if you're going fast enough, the tunnel is unfortunately not long enough to cover up the load time, and you can catch the act 2 background while it's still decompressing. ICZ1's transition event specifically only waits for the Kosinski-compressed chunks/blocks to decompress and not the KosM tiles, almost certainly because otherwise, you would run straight into the act 1 loopback as the art slowly loads.
    tst.w   (Kos_decomp_queue_count).w
    bne.w   loc_53938
    move.w  #$501,(Current_zone_and_act).w
There's another quirk with this transition, and it has to do with the previously mentioned star post. If you hit it, and then enter/exit the bonus stage or lose a life, the title card will read "act 1", but the music playing will be that of act 2.

Internally, the game tracks two distinct values for the current zone and act. The first one is the actual zone and act, and picks which level actually gets loaded. The other is the apparent zone and act, which is what the game says the current level is. Specifically, the title card object uses the apparent act to give you an act 1 card when you're really in act 2.
    lea     (ArtKosM_TitleCardNum2).l,a1
    cmpi.w  #$1600,(Current_zone_and_act).w
    beq.s   loc_2D716
    cmpi.w  #$1700,(Current_zone_and_act).w
    beq.s   loc_2D716                       ; Death Egg Boss and LRZ Boss show act 2
    tst.b   (Apparent_act).w
    bne.s   loc_2D716
    lea     (ArtKosM_TitleCardNum1).l,a1

    move.w  #$A7A0,d2
    jsr     (Queue_Kos_Module).l
    lea     TitleCard_LevelGfx(pc),a1
The code that picks the level's music however, does not, so it picks the song for the level you're actually in, which is act 2. But wait a second. Angel Island Zone 1 also features a star post directly after the act transition, but when you spawn there, the game correctly plays the act 1 song.

Actually, the logic for the AIZ1 starpost is completely hardcoded into the music picking code. When you respawn from a big ring, the value of Last_star_post_hit is not restored by the time this code runs, so it picks the act 2 song again.
    move.w  (Current_zone_and_act).w,d0
    ror.b   #1,d0
    lsr.w   #7,d0
    lea     (LevelMusic_Playlist).l,a1
    move.b  (a1,d0.w),d0
    cmpi.w  #1,(Current_zone_and_act).w
    bne.s   loc_622A
    cmpi.b  #3,(Last_star_post_hit).w
    bne.s   loc_622A
    moveq   #1,d0

    move.w  d0,(Level_music).w
    bsr.w   Play_Sound
Incidentally, this code is not present in standalone Sonic 3, so spawning in the act 2 layout will always play act 2 music.

Tuesday, August 29, 2017

Act transitions, part 2: Angel Island Zone

Angel Island Zone's act transition does not happen during the act 1 results screen. Instead, it takes place much earlier in the stage, at the act 1 boss cutscene encounter where the entire level catches on fire.

The cutscene area at the very end of the act 1 layout is interesting in that all the background tiles, including the sky and the horizon line, are actually part of the level's foreground plane. Furthermore, all tiles are set to low priority, as is every sprite that appears throughout the cutscene.

The reason for this should be clear: the game is about to pull some shenanigans using the background plane. Although they're usually drawn behind the foreground plane, by setting the background's tiles to high priority, we can make them render in front of the foreground. This trick is used to display the scrolling flames that cover up the entire screen, during which the act transition silently takes place.

Finally, there's an extra trick that brings the whole cutscene together. As the boss descends from the top of the screen, several more Fire Breath robots scroll across the sky in the background, logically going behind the palmtree on the left side of the screen. But as we know now, the background tiles in this area are all set to low priority, so the robots' sprites should be getting drawn in front of the tree!

The trick here is to introduce an extra sprite where the robots intersect with the tree. This sprite also needs to be set to low priority so it's covered by the background flames, but by giving it a low priority value on its SST, we can ensure it's drawn on top of the robots, but still behind Sonic and everything else.

Monday, August 28, 2017

Act transitions, part 1

An act transition is a special event which happens at the end of act 1, and allows the player to progress seamlessly into act 2 as if the two levels are physically connected to each other. Apart from a few corner cases, an act transition begins when the characters settle into their victory poses, right before the level results come up.

The first step towards achieving a seamless transition is to replicate the end of act 1 at the start of the act 2 layout. This is where splitting level data into "primary" and "secondary" sets comes into play: by ensuring only tiles from the primary set are visible during the act 1 results screen, act 1's secondary tile set can be swapped out for act 2's without affecting anything present on screen.

Covering up the background plane with foreground tiles makes for an easier transition, because then the position of the background scroll doesn't have to be maintained between acts. It also lets act 2 use a completely different background, without ever having to reconcile the two. Finally, it frees up act 1's background tiles to be overwritten, either by the act 2 tiles or by the boss' own graphics.

Below is the Hydrocity Zone 1 boss area, as it appears in the act 1 and act 2 level layouts. Interestingly, the act 2 entry corridor is present in the act 1 layout, even though the player can never get there legitimately.

Let's do a quick breakdown of the intervening code:
    move.w  ($FFFFEEC2).w,d0
    jmp     loc_50BC6(pc,d0.w)
; ---------------------------------------------------------------------------

    bra.w   HCZ1BGE_Normal
; ---------------------------------------------------------------------------
    bra.w   HCZ1BGE_DoTransition
; ---------------------------------------------------------------------------

    tst.w   ($FFFFEEC6).w
    beq.s   loc_50C2A
First, the background event function waits for the signal from the level results object at RAM address $EEC6. While the transition has yet to start, loc_50C2A simply draws the HCZ1 background as usual.
    clr.w   ($FFFFEEC6).w                       ; Do transition
    movem.l d7-a0/a2-a3,-(sp)
    lea     (HCZ2_128x128_Secondary_Kos).l,a1
    lea     ($FFFF0A00).l,a2
    jsr     (Queue_Kos).l
    lea     (HCZ2_16x16_Secondary_Kos).l,a1
    lea     ($FFFF9558).w,a2
    jsr     (Queue_Kos).l
    lea     (HCZ2_8x8_Secondary_KosM).l,a1      ; Load secondary HCZ2 art, blocks, and chunks
    move.w  #$2360,d2
    jsr     (Queue_Kos_Module).l
As soon at the signal goes off, the background event function goes to work loading the secondary level data. This does not use the level load block at all, and is surprisingly hardcoded for every level. As best as I can tell, this is because the level load block doesn't contain any information about the size of the primary data once it's decompressed, so it doesn't know where to decompress the secondary data to without first decompressing the primary data.
    moveq   #$10,d0
    jsr     (Load_PLC).l
    moveq   #$11,d0
    jsr     (Load_PLC).l                        ; load HCZ2 PLCs
    movem.l (sp)+,d7-a0/a2-a3
    st      ($FFFFEEE8).w
    addq.w  #4,($FFFFEEC2).w

    jsr     HCZ1_Deform(pc)
    lea     (Camera_Y_pos_BG_copy).w,a6
    lea     (Camera_Y_pos_BG_rounded).w,a5
    moveq   #0,d1
    moveq   #$20,d6
    jsr     Draw_TileRow(pc)
    lea     (HCZ1_BGDeformArray).l,a4
    lea     ($FFFFA800).w,a5
    jmp     ApplyDeformation(pc)
; ---------------------------------------------------------------------------
After queueing a couple of PLCs, the background event routine counter at RAM address $EEC2 gets incremented, and so beginning next frame, HCZ1BGE_DoTransition will run instead.
    tst.b   (Kos_modules_left).w
    bne.w   loc_50CDC               ; Don't do anything else until kos queue has been cleared
This one's kind of important. Nothing happens until all the level data is done decompressing.
    move.w  #$101,(Current_zone_and_act).w      ; Change the act
    clr.b   (Dynamic_resize_routine).w          ; Reload resize routine counter
    clr.b   (Object_load_routine).w             ; Reload sprite manager
    clr.b   (Rings_manager_routine).w           ; Reload ring manager
    clr.b   (Boss_flag).w                       ; Unlock the screen
    clr.b   ($FFFFFF97).w                       ; Refresh sprite/ring memory
    jsr     Clear_Switches(pc)
    move.l  #Obj_HCZWaterSplash,($FFFFB172).w   ; Load the splash object
    move.b  #1,($FFFFB19E).w
Because of this. Right off the bat, the current level value in RAM is changed to zone $1, act $1, more commonly known as Hydrocity Zone 2. Once this happens, the next time the function runs, it will run the background events for act 2, so this is the last frame in which to do everything that's still left over.
    movem.l d7-a0/a2-a3,-(sp)
    jsr     (Load_Level).l                      ; Load HCZ2 layout
    jsr     (LoadSolids).l
    jsr     (CheckLevelForWater).l
    move.w  #$6A0,d0
    move.w  d0,(Water_level).w
    move.w  d0,(Mean_water_level).w
    move.w  d0,(Target_water_level).w           ; Set the water up
    moveq   #$D,d0
    jsr     (LoadPalette_Immediate).l           ; Load HCZ2 palette
    movem.l (sp)+,d7-a0/a2-a3
The Load_Level and LoadSolids functions load the act 2 level layout and collision data. Since both of them are stored uncompressed in the ROM, they can be quickly loaded within a single frame, ensuring there is no gap where the level's solidity is compromised.
    move.w  #$3600,d0
    moveq   #0,d1
    sub.w   d0,($FFFFB010).w
    sub.w   d0,($FFFFB05A).w
    jsr     Offset_ObjectsDuringTransition(pc)
    sub.w   d0,(Camera_X_pos).w
    sub.w   d0,(Camera_X_pos_copy).w
    sub.w   d0,(Camera_min_X_pos).w
    sub.w   d0,(Camera_max_X_pos).w         ; Offset objects/camera position by specified amount
    jsr     Reset_TileOffsetPositionActual(pc)
The Offset_ObjectsDuringTransition function goes through every active SST in object RAM, and adjusts their X and Y positions based on the values stored in the d0 and d1 registers. The act 1 boss area is $3600 pixels further to the left in the act 2 layout, so this makes it seem like everything stays in place.
    clr.w   ($FFFFEEC2).w

    jsr     HCZ1_Deform(pc)
    lea     (Camera_Y_pos_BG_copy).w,a6
    lea     (Camera_Y_pos_BG_rounded).w,a5
    moveq   #0,d1
    moveq   #$20,d6
    jsr     Draw_TileRow(pc)
    lea     (HCZ1_BGDeformArray).l,a4
    lea     ($FFFFA800).w,a5
    jmp     ApplyDeformation(pc)
Finally, the routine counter at $EEC2 is cleared, ready to start running background events for HCZ2. Strangely enough, after this the code falls directly into loc_50CDC, a duplicate of loc_50C2A that renders HCZ1's background, which isn't really there anymore. Oh well, it's just for this one frame.

Next, we'll take a look at those corner cases I mentioned at the start.

Friday, August 25, 2017

Screen events and background events

Alright, some more plumbing to wrap up the week. Aside from the dynamic resize routines, every level has a set of four additional functions: screen init, screen event, background init and background event. The "init" functions, as the name implies, run once at level load. Meanwhile, the "event" functions, much like the resize routines, run once every frame.
Offs_ScreenInit:       dc.l AIZ1_ScreenInit
Offs_BackgroundInit:   dc.l AIZ1_BackgroundInit
                       dc.l AIZ2_ScreenInit
                       dc.l AIZ2_BackgroundInit
Offs_ScreenEvent:      dc.l AIZ1_ScreenEvent
Offs_BackgroundEvent:  dc.l AIZ1_BackgroundEvent
                       dc.l AIZ2_ScreenEvent
                       dc.l AIZ2_BackgroundEvent
                       dc.l HCZ1_ScreenInit
                       dc.l HCZ1_BackgroundInit
                       dc.l HCZ2_ScreenInit
                       dc.l HCZ2_BackgroundInit
                       dc.l HCZ1_ScreenEvent
                       dc.l HCZ1_BackgroundEvent
                       dc.l HCZ2_ScreenEvent
                       dc.l HCZ2_BackgroundEvent
The main job of these functions is actually to render the background layers, applying all the stage-specific scroll effects such as distortions and parallax. The "screen" functions are responsible for drawing the foreground plane, whereas the "background" functions draw, well, the background plane. Much like the dynamic resize routines, they're often split into several states, indexed by a value stored in RAM.
    move.w  ($FFFFEECE).w,d0
    add.w   d0,(Camera_Y_pos_copy).w
    move.w  ($FFFFEEC0).w,d0
    jmp     loc_4FF26(pc,d0.w)
; ---------------------------------------------------------------------------

    bra.w   AIZ2SE_Normal
; ---------------------------------------------------------------------------
    bra.w   AIZ2SE_ShipRefresh
; ---------------------------------------------------------------------------
    bra.w   AIZ2SE_ShipDraw
; ---------------------------------------------------------------------------
    bra.w   AIZ2SE_EndRefresh
; ---------------------------------------------------------------------------
    bra.w   AIZ2SE_End
; ---------------------------------------------------------------------------
Moreover, these functions frequently perform the kind of tasks a resize routine would, in levels that don't use them. For instance, Mushroom Hill Zone 1's screen event function is responsible for vertically locking the camera at the end of the level and spawning the act 1 boss object.
    tst.b   ($FFFFEED2).w
    bne.s   loc_54B7C
    tst.w   ($FFFFEEE8).w
    bne.s   loc_54B7C
    jsr     sub_54B80(pc)
    move.w  #$AA0,d0
    cmpi.w  #$4100,($FFFFB010).w
    blo.s   loc_54B40
    move.w  #$710,d0

    cmp.w   (Camera_max_Y_pos).w,d0
    beq.s   loc_54B4E
    move.w  d0,(Camera_max_Y_pos).w
    move.w  d0,(Camera_target_max_Y_pos).w

    cmpi.w  #$710,(Camera_Y_pos).w
    blo.s   loc_54B7C
    cmpi.w  #$4298,(Camera_X_pos).w
    blo.s   loc_54B7C
    move.w  #$710,(Camera_min_Y_pos).w
    move.w  #8,($FFFFEEB2).w
    st      ($FFFFEED2).w
    jsr     (Create_New_Sprite).l
    bne.s   loc_54B7C
    move.l  #Obj_MHZ_Miniboss,(a1)

    jmp     DrawTilesAsYouMove(pc)
This kind of stuff can just as easily go on the background event function though, particularly when it involves using the solid background flag. Generally speaking, either function is suitable for executing arbitrary logic throughout the whole stage. There's one thing in particular that the act 1 background event functions are responsible for, though.
    move.w  ($FFFFEEC2).w,d0
    jmp     loc_50BC6(pc,d0.w)
; ---------------------------------------------------------------------------

    bra.w   HCZ1BGE_Normal
; ---------------------------------------------------------------------------
    bra.w   HCZ1BGE_DoTransition
; ---------------------------------------------------------------------------
That's right, we're finally taking a look at act transitions next. You know, those things that crash Sonic Mania unless you stay perfectly still.

Thursday, August 24, 2017

Hide your shame

Reader muteKi pointed out something interesting over at my post on level sizes. By default, Carnival Night Zone 2 has an unusually low ceiling:
;                       xstart  xend    ystart  yend    ; Level
                dc.w    $0,     $6000,  $580,   $1000   ; CNZ2
The reason for the lower ceiling is to keep you from going back up the shaft and reaching the innards of the act 1 boss scroll, which are replicated in the act 2 layout for some reason. This would be trivial to do by say, climbing up the shaft walls as Knuckles.

However, something was still bugging me. I was sure you could go up there somehow. So I checked my disassembly of standalone Sonic 3, and sure enough, what do you know:
;                       xstart  xend    ystart  yend    ; Level
                dc.w    $0,     $6000,  $0,     $1000   ; CNZ2
The ceiling is completely unlocked in standalone Sonic 3. The addition of Knuckles as a playable character is likely the reason why this was changed. Even so, if you select Sonic and Tails, use the act 1 signpost to reveal the thunder barrier monitor hidden in the floor, then have Tails carry Sonic up as high as possible, and finally use the barrier to do a double jump, you can just barely get the camera outside the shaft. It's not enough to get Sonic up there, though.

While investigating this, I noticed the level size for Launch Base Zone 2 is also different between games. Here's what it looks like in Sonic & Knuckles:
;                       xstart  xend    ystart  yend    ; Level
                dc.w    $0,     $6000,  $0,     $B20    ; LBZ2
And here's what it looks like in Sonic 3:
;                       xstart  xend    ystart  yend    ; Level
                dc.w    $0,     $6000,  $0,     $1000   ; LBZ2
In Sonic 3, the level's pants are down, so if you go all the way to the bottom, you can see where the background plane ends and also get a good look at the level's sewers. This was fixed in Sonic & Knuckles due to Knuckles' route through the stage making this pretty evident.

It's not really an issue in Sonic 3 because Sonic and Tails never go low enough to actually see it. Once again, the level size was changed due to the addition of Knuckles as playable character.

Wednesday, August 23, 2017

The game is confused

Although dynamic resize routines are only meant for, well, dynamically resizing the level, there's nothing stopping them from handling related events. For instance, the resize routines for both Angel Island Zone 1 and Marble Garden Zone 2 manually spawn the boss object into the level once the left boundary is locked in place.
    moveq   #0,d0
    move.b  (Dynamic_resize_routine).w,d0
    move.w  off_1C92A(pc,d0.w),d0
    jmp     off_1C92A(pc,d0.w)
; ---------------------------------------------------------------------------
    dc.w loc_1C930-off_1C92A
    dc.w loc_1C96E-off_1C92A
    dc.w locret_1C9C8-off_1C92A
; ---------------------------------------------------------------------------

    cmpi.w  #$3A00,(Camera_X_pos).w
    blo.s   loc_1C9A8
    move.w  #$3C80,d0
    cmp.w   (Camera_X_pos).w,d0
    bhi.s   locret_1C9C6
    move.w  d0,(Camera_min_X_pos).w
    move.w  d0,(Camera_target_min_X_pos).w
    jsr     (Create_New_Sprite).l
    bne.s   loc_1C9A2
    move.l  #Obj_A1_1_MGZ2_Boss,(a1)
    move.w  #$3D20,$10(a1)
    move.w  #$668,$14(a1)

    addq.b  #2,(Dynamic_resize_routine).w
I intentionally sneaked in a weird quirk in my previous post related to this. Can you figure out what it is? I'll wait.




Welcome back.
    dc.w MGZ_Resize-LevelResizeArray
    dc.w MGZ_Resize-LevelResizeArray
For whatever reason, Marble Garden Zone 1 uses the same set of dynamic resize routines as Marble Garden Zone 2. In practice, this does not affect act 1 at all, because act 2 is longer than act 1, and all the interesting stuff only happens beyond the natural end of act 1.

However, because the developers were lazy setting most of the level sizes, so as long as you avoid spawning the act 1 boss object, going beyond the end of the level will put you in the act 1 loopback. And sure enough, once you're there, if you make your way to the right coordinates, the act 2 boss will spawn in act 1.

This is used in the current speedrun world record in order to completely skip over Marble Garden Zone 2.

Tuesday, August 22, 2017

Dynamic resize routines

Along with the camera bound variables we saw last time, there are also four matching "target" values, which are meant to smoothly change a level's size, such as after a boss fight. However, in Sonic 3, only Camera_target_max_Y_pos is wired up, and the other three do absolutely nothing. The code which gradually moves Camera_max_Y_pos towards its target position is in the Do_ResizeEvents function.
    Camera_target_min_X_pos = ramaddr( $FFFFEE0C ) ; word
    Camera_target_max_X_pos = ramaddr( $FFFFEE0E ) ; word
    Camera_target_min_Y_pos = ramaddr( $FFFFEE10 ) ; word
    Camera_target_max_Y_pos = ramaddr( $FFFFEE12 ) ; word
The Do_ResizeEvents function is also responsible for running each level's dynamic resize routines. These are called once every frame, and serve as a place where each stage can run logic to modify the level size based on the camera's current position.
    cmpi.w  #$740,(Camera_X_pos).w
    blo.s   locret_1CA3A
    cmpi.w  #$400,(Camera_Y_pos).w
    bhs.s   locret_1CA3A
    move.w  #$740,(Camera_min_X_pos).w
    addq.b  #2,(Dynamic_resize_routine).w

For instance, in the routine for Icecap Zone 2 above, the game checks if the camera's X position is $740 or greater, and if so, locks the left boundary at the act 1 boss area. Note how it doesn't happen when the camera's Y position is greater than $400 for some reason: as a result, Knuckles can backtrack all the way to the pre-boss star post.

Similarly to "old"-style objects, resize routines behave like a state machine, with the current routine selected through an index byte stored somewhere in RAM. This byte is saved when you touch a star post or a special stage ring, so that the level is always in the appropriate state when the player respawns.
    moveq   #0,d0
    move.b  (Dynamic_resize_routine).w,d0
    move.w  off_1C9DA(pc,d0.w),d0
    jmp     off_1C9DA(pc,d0.w)
; ---------------------------------------------------------------------------
    dc.w loc_1C9E0-off_1C9DA
    dc.w loc_1C9FA-off_1C9DA
    dc.w locret_1CA0C-off_1C9DA
; ---------------------------------------------------------------------------
Much like the camera target variables though, dynamic resize routines have fallen out of style in the Sonic 3 codebase, with few stages actually using them. None of the stages in the Sonic & Knuckles half of the game have resize routines, but the system has to be carried around for backwards compatibility with the Sonic 3 stages.
    dc.w AIZ1_Resize-LevelResizeArray
    dc.w AIZ2_Resize-LevelResizeArray
    dc.w HCZ1_Resize-LevelResizeArray
    dc.w HCZ2_Resize-LevelResizeArray
    dc.w MGZ_Resize-LevelResizeArray
    dc.w MGZ_Resize-LevelResizeArray
    dc.w No_Resize2-LevelResizeArray
    dc.w No_Resize2-LevelResizeArray
    dc.w No_Resize2-LevelResizeArray
    dc.w No_Resize2-LevelResizeArray
    dc.w ICZ1_Resize-LevelResizeArray
    dc.w ICZ2_Resize-LevelResizeArray
    dc.w No_Resize-LevelResizeArray
    dc.w LBZ2_Resize-LevelResizeArray
    dc.w No_Resize3-LevelResizeArray
    dc.w No_Resize3-LevelResizeArray
    dc.w No_Resize3-LevelResizeArray
    dc.w No_Resize3-LevelResizeArray

Monday, August 21, 2017

Level sizes

A while ago I talked about level sizes without getting into much detail. Basically, there's a set of four RAM values which serve as upper and lower bounds for the camera's X and Y position:
    Camera_min_X_pos = ramaddr( $FFFFEE14 ) ; word
    Camera_max_X_pos = ramaddr( $FFFFEE16 ) ; word
    Camera_min_Y_pos = ramaddr( $FFFFEE18 ) ; word
    Camera_max_Y_pos = ramaddr( $FFFFEE1A ) ; word
The origin point (0,0) of a level is at its top left corner, so Camera_min_X_pos and Camera_max_X_pos correspond to the left and right level boundaries, where as Camera_min_Y_pos and Camera_max_Y_pos correspond to the top and bottom level boundaries, respectively. When a level is first loaded, these addresses are initialized to the values in the LevelSizes data structure:
;                       xstart  xend    ystart  yend    ; Level
LevelSizes:     dc.w    $1308,  $6000,  $0,     $390    ; AIZ1
                dc.w    $0,     $4640,  $0,     $590    ; AIZ2
                dc.w    $0,     $6000,  $0,     $1000   ; HCZ1
                dc.w    $0,     $6000,  $0,     $1000   ; HCZ2
                dc.w    $0,     $6000,  -$100,  $1000   ; MGZ1
                dc.w    $0,     $6000,  $0,     $1000   ; MGZ2
                dc.w    $0,     $6000,  $0,     $B20    ; CNZ1
                dc.w    $0,     $6000,  $580,   $1000   ; CNZ2
                dc.w    $0,     $2E60,  $0,     $B00    ; FBZ1
                dc.w    $0,     $6000,  $0,     $B00    ; FBZ2
It should be noted that all the camera positions stored in these addresses correspond to the pixel displayed at the top left corner of the screen. That means in order to figure out the actual level dimensions, you need to add the screen's width (320 pixels) to the max X position, and the screen's height (224 pixels) to the max Y position.
                dc.w    $60,    $60,    $0,     $240    ; Gumball
                dc.w    $60,    $60,    $0,     $240    ; Gumball
                dc.w    $0,     $140,   $0,     $F00    ; Pachinko
                dc.w    $0,     $140,   $0,     $F00    ; Pachinko
For instance, in the pachinko/glowing spheres stage, although Camera_max_X_pos is set to $140 (320 pixels), since there are already 320 pixels horizontally on screen when Camera_X_pos is zero, scrolling to the right reveals another 320 pixels, for a total level width of 640 pixels. On the flipside, despite Camera_min_X_pos and Camera_max_X_pos both being set to $60, the gumball stage's width isn't actually zero. It just can't scroll horizontally. However, at the very least it's still as wide as the screen, so the actual level width is 320 pixels.

While debug mode is active, the score display is replaced by four counters, which show the player and the camera's current position. This makes it invaluable for testing events triggered by those values. In the example above, 0D00 is the player's X position and 0178 is their Y position, while 0C60 is the camera's X position and 00F8 is its Y position.

As an aside, the time display is also replaced by a counter. This one shows how many sprites are being displayed, in decimal. The reason it alternates between N and N+2 when you have no rings is because the "RINGS" portion of the HUD blinks in and out of existence, and since it's composed of two sprites, one for the "RING" and another for the S, there are two more sprites being displayed on frames in which it's visible.

Exercise: Why is the left boundary for Angel Island Zone 1 set to such a high value?

Friday, August 18, 2017

Contextual graphics, part 4: mind your head

In standalone Sonic 3, if you go to the entrance of Knuckles' route in Marble Garden Zone 2, you'll find that the bottom camera boundary gets locked in anticipation of the cutscene boss encounter. In Sonic 3 & Knuckles, however, this was modified to lock the top boundary as well.

The reason for this becomes evident when you enter the cutscene area in standalone Sonic 3. Yes, the cutscene does work; in fact, the entirety of Knuckles' route all the way up to the boss area works just fine, and interestingly, the object layout is actually slightly different to the one in S3&K. Be sure to check it out!

Anyway, the problem is, when the boss loads its graphics, it overwrites the graphics used by the Relief enemy, causing it to appear as a jumbled mess if it scrolls into view. In order to quickly work around this, the developers made it so the top camera boundary locks along with the bottom one for this cutscene only.

What's interesting is how they handle releasing the camera lock. If you hold up the entire time, you'll realize the camera remains locked for a seemingly arbitrary duration after the cutscene has already ended. Well, the problem was showing the Relief while its graphics aren't loaded, right?

So in this case, the game waits for the Nemesis decompression queue to be empty before releasing the camera. Once the queue is empty, the Relief's graphics are definitely done loading, which means it's safe to scroll it back into view.

Thursday, August 17, 2017

Contextual graphics, part 3: frozen palette

When the end level sign lands in Lava Reef Zone 1, the palette animation on the foreground rocks suddenly freezes on whichever color it was at the time. Unlike previous cases however, this is not mandated by any particular line of code: it happens because the game silently transitions to act 2, which features no such animated palette!

It looks particularly dumb when it freezes on the bright purple color, which in turn is exacerbated by the fact that, at the start of the boss fight, the palette used by the foreground rocks is dimmed significantly. This was likely done in order to make the boss' giant hand stand out more as it emerges from the bottom of the screen.

Wednesday, August 16, 2017

Contextual graphics, part 2: frozen graphics

The entrance to the boss in Hydrocity Zone 1 is quite similar to the ghost capsule in Sandopolis Zone 2. You go around a loop and then fall to the room below, where once again, new graphics have stealthily been loaded through a PLC call, in order to render an object which is immediately visible. This time it's the agitator at the center of the boss area.

As for the mechanism itself, however, it could not be more different. The graphics for the boss and the agitator are both in the same Nemesis archive, and it's the actual boss object located beneath the loop which spawns the agitator object and loads the PLC, soon after it is scrolled within the range of the object manager.

There's a snag, though. The boss loads its graphics over the DMA region for the animated elements earlier in the level, such as conveyor belts and the pseudo-3D waterline. If those elements continue running, they'll end up overwriting the boss art, so before loading it the boss sends out a signal to stop all of the level's animated PLCs, which works, but also causes the pendulum cages right before the loop to suddenly freeze in whichever frame they were at the time.

Bizarrely enough, the same thing occurs at the boss fight in Angel Island Zone 1, and I can't figure out why. The plants in the foreground and the flames in the background all stop moving as soon as the screen locks, and then resume once the boss is defeated. Maybe there was an art conflict here at one point?

Tuesday, August 15, 2017

Contextual graphics, part 1

The capsule near the start of Sandopolis Zone 2 uses graphics which do not reappear until the after the boss fight, and so it would be a waste of VRAM if they were loaded the entire time. So instead, the graphics are loaded on the spot via PLC call, much like we saw before in Marble Garden Zone 2.

Unlike the MGZ2 boss however, which is initially hidden from view, the capsule is immediately visible upon spawn. This makes it unsuitable for the capsule to load its own graphics as it spawns: you would catch the art being decompressed on the fly. The solution the developers found was to place a helper object on the loop above the capsule.

Once the player is within range of the object, it loads the capsule's PLC over the graphics for the Skorp and Sandworm enemies. The object's range is quite generous, though, and it is entirely possible to cause the capsule graphics to load without actually taking the loop. This would normally be fine, since there aren't any enemies in the immediate area.

However! If you push the first door in the level all the way open, and then quickly make your way over to the loop, there should be just enough time for you to step into the helper object's range and make it back to the door before it closes.

By the way, the Sandworm enemy's regular appearance is more than just inspired by the Caterkiller enemy in Sonic 1.

Monday, August 14, 2017

A ghost's pumpkin soup

Welcome back. When playing Sandopolis Zone 2 as Sonic or Tails, there actually aren't any ghosts at the very start of the stage. A little bit later, you're forced to break open an animal capsule, releasing the ghouls from within.

Many years ago, the legendary GoldS demonstrated that if you manage to avoid breaking the capsule, then the ghosts appear as soon as you touch a star post. It makes sense: there are no star posts prior to the capsule, so this is a good way to ensure the ghosts are present throughout the rest of the stage, even after you die or enter a bonus stage.

However, it's actually simpler than that. All the ghosts do is wait for Last_star_post_hit to have a non-zero value. When the capsule breaks, it triggers a fake star post with the same X/Y position as the regular act 2 start coords. As a result, if you lose a life after breaking the capsule but before touching the first star post, you'll respawn back at the start of the stage, except with the ghosts already out and a non-zero timer.

Friday, August 11, 2017

What is a fake star post?

First, an overview of how star posts work. Each star post in a level is numbered sequentially, more or less according to their relative placement in the level. This number is stored in the star post object's subtype. When an inactive star post is touched, it writes its number to the Last_star_post_hit RAM address. The numbering starts at 1, and 0 serves as a sentinel value meaning "no star posts hit in the current level."

However, a star post's number isn't actually used when respawning the player; all it does is cause every star post with a number lower or equal to Last_star_post_hit already be activated on spawn. Instead, the player's position, as well as a bunch more information, is stored to a backup area in RAM. Note that this is different from the backup area used for big rings, because we still want the player to respawn at the last star post even after entering a Special Stage.
    move.b  $2C(a0),(Last_star_post_hit).w
    move.w  $10(a0),(Saved_X_pos).w
    move.w  $14(a0),(Saved_Y_pos).w

    move.b  (Last_star_post_hit).w,(Saved_last_star_post_hit).w
    move.w  (Current_zone_and_act).w,(Saved_zone_and_act).w
    move.w  (Apparent_zone_and_act).w,(Saved_apparent_zone_and_act).w
    move.w  (Player_1+art_tile).w,(Saved_art_tile).w
    move.w  (Player_1+top_solid_bit).w,(Saved_solid_bits).w
    move.w  (Ring_count).w,(Saved_ring_count).w
    move.b  (Extra_life_flags).w,(Saved_extra_life_flags).w
    move.l  (Timer).w,(Saved_timer).w
    move.b  (Dynamic_resize_routine).w,(Saved_dynamic_resize_routine).w
    move.w  (Camera_max_Y_pos).w,(Saved_camera_max_Y_pos).w
    move.w  (Camera_X_pos).w,(Saved_camera_X_pos).w
    move.w  (Camera_Y_pos).w,(Saved_camera_Y_pos).w
    move.w  (Mean_water_level).w,(Saved_mean_water_level).w
    move.b  (Water_full_screen_flag).w,(Saved_water_full_screen_flag).w
Here we can see that the code which saves the player's state is split into two sections: first, a bit where the star post's subtype and X/Y position are backed up, falling directly into Save_Level_Data, which backs up everything else. When a level starts, the game checks the value of Last_star_post_hit: if it is non-zero, it initializes everything to the contents of the backup RAM area.

So what is a fake star post? A fake star post is any other code that writes to Last_star_post_hit, saves a custom set of X/Y positions, and then calls Save_Level_Data. There are six such spots in the game, mostly to permanently skip over lengthy cutscenes. All fake star posts in the game occur at the start of the level and set Last_star_post_hit to 1, with the actual star post numbering in those levels starting at 2 instead.

  • The first one occurs in Angel Island 1, when you leave the starting area and enter the main level. This skips over the Knuckles cutscene when playing as Sonic.
  • The next one is in Mushroom Hill 1, but only in Sonic 3 & Knuckles. It happens when the cutscene showing Knuckles leaving the big ring room ends, and skips it on subsequent times.
  • Also in Mushroom Hill 1, but only in standalone Sonic & Knuckles. When Knuckles' intro ends, a star post is activated and the level restarts in order to spawn Knuckles in the main level rather than the intro area.
  • In Mushroom Hill 2, when Sonic and Tails are blasted up into the autumn section of the level. This does end up making a couple of rings permanently missable.
  • In Sky Sanctuary as Sonic and Tails, the same fake star post is activated twice for some reason: once when the bridge off the intro section extends, and another once Knuckles dissappears.

And now, although I've already told you the locations of five of the six fake star posts in the game, I'm delaying the last one over to next week. Sorry about that; as a consolation, please enjoy this video of a cat playing the piano.

Thursday, August 10, 2017

Play as Failure Cresh in (almost) every level

What if you want to play as Failure Cresh, but can't find an object that kills you while you're in object placement mode? Don't worry, there's another way. Provided you haven't yet collected all the Chaos Emeralds, all you have to do is jump inside a big ring, and then enter object placement mode before you're taken to the Special Stage.

When the animation ends, the ring object saves a bunch of information about the player's current state to a safe area in RAM. This information is then recalled at the end of the Special Stage, in order to restore the player to more or less the same state as when the game was interrupted.
    move.b  (Last_star_post_hit).w,(Saved2_last_star_post_hit).w
    move.w  (Current_zone_and_act).w,(Saved2_zone_and_act).w
    move.w  (Apparent_zone_and_act).w,(Saved2_apparent_zone_and_act).w
    move.w  x_pos(a0),(Saved2_X_pos).w
    move.w  y_pos(a0),(Saved2_Y_pos).w
    move.w  (Player_1+art_tile).w,(Saved2_art_tile).w
    move.w  (Player_1+top_solid_bit).w,(Saved2_solid_bits).w
    move.w  (Ring_count).w,(Saved2_ring_count).w
    move.b  (Extra_life_flags).w,(Saved2_extra_life_flags).w
    move.l  (Timer).w,(Saved2_timer).w
    move.b  (Dynamic_resize_routine).w,(Saved2_dynamic_resize_routine).w
    move.w  (Camera_max_Y_pos).w,(Saved2_camera_max_Y_pos).w
    move.w  (Camera_X_pos).w,(Saved2_camera_X_pos).w
    move.w  (Camera_Y_pos).w,(Saved2_camera_Y_pos).w
    move.w  (Mean_water_level).w,(Saved2_mean_water_level).w
    move.b  (Water_full_screen_flag).w,(Saved2_water_full_screen_flag).w
    move.b  (Player_1+status_secondary).w,(Saved2_status_secondary).w
One of the backed up addresses is the player's art tile, for the same reason as before: so that the player respawns with the priority they originally had when they entered the ring.

What if the level you're in doesn't have any big rings? Well, you could use star posts, which perform a similar operation using the Save_Level_Data function... except the function gets called right when you touch the star post, and you can't touch star posts in object placement mode.

However! There's a special kind of star post which activates on its own: the fake star post. That'll be the subject of the next post. Watch out for those Mania spoilers, guys.

Wednesday, August 9, 2017

You can now play as Failure Cresh

Last time, I mentioned how when you enter object placement mode, the game stores away player 1's mappings and art pointer to some RAM address off in the boonies. But if we take another look, we can see the latter actually isn't backed up all the time.
    addq.b  #2,(Debug_placement_mode).w
    move.l  $C(a0),($FFFFFFCA).w        ; save mappings to $FFCA
    cmpi.b  #6,5(a0)
    bhs.s   loc_92A38
    move.w  $A(a0),($FFFFFFCE).w        ; save art tile to $FFCE

Specifically, it checks if the player's routine byte is 6 or higher, and if so, doesn't back up the art tile. These just happen to be the states in which the player is dead; in particular routine 6 indicates that the player is bouncing up in their death frame, but hasn't reached the bottom of the screen yet.

Routine 6 is special because it has an escape hatch. If debug mode is available and the B button is pressed, the player can avoid death by entering object placement mode. Here's the code responsible for killing a player:
    clr.b   $2B(a0)
    clr.b   $37(a0)
    move.b  #6,5(a0)                    ; change player to death state
    move.w  d0,-(sp)
    jsr     (Player_TouchFloor).l
    move.w  (sp)+,d0
    bset    #1,$2A(a0)
    move.w  #-$700,$1A(a0)
    move.w  #0,$18(a0)
    move.w  #0,$1C(a0)
    move.b  #$18,$20(a0)
    move.w  $A(a0),($FFFFFFCE).w        ; save art tile to $FFCE
    bset    #7,$A(a0)                   ; set priority flag
    jsr     (Play_Sound_2).l
    moveq   #-1,d0
Recall that art tile is a VDP pattern index of the form PCCXYAAAAAAAAAAA, used as the base pattern index for every sprite of an object. Setting the high bit on the art tile effectively sets the player's sprite to high priority, so that it appears to "fall off the level" rather than disappear into the floor.

Due to routine 6's escape hatch, the current art tile is first saved to $FFCE. This ensures that if the player cheats death by entering object placement mode, then upon exiting it the player's priority will be restored to how it was pre-mortem. The normal backup on debug entry is thus skipped to avoid overwriting the one done here.

However, once again we have run into code which is not careful with the pattern bits. The entire pattern index is saved on death, rather than just the priority bit. This means that if the art tile currently has a dirty value, say, due to the player having died while in object placement mode, then it's actually the dirty value that's being saved.

All you have to do is pick your favorite item from the debug reel, and find something that can kill you even when you're in object placement mode, such as Big Arm, the frost cannons in Icecap Zone, or the tilting lava in the Lava Reef Zone boss act. When you die, the saved art tile at $FFCE gets replaced by the art tile of the debug item, which means once you exit debug mode, you can finally play the game as Failure Cresh.

This looks particularly nice when you jump or roll, since all the spinning frames are just single 4x4 mappings. It looks especially nice as Tails, since you'll still have his titular appendages sticking out.

Tuesday, August 8, 2017

Welcome to the next level

Internet's out, so I'm posting this from my phone. Earlier today we got our first look at Sonic Mania's competition mode. Through sheer coincidence, this lines up with a subject I was saving for a rainy day.

The Sonic 3 title card object has unused title card definitions for Competition mode, hidden behind a flag which is never enabled. Normally they don't display correctly due to VRAM conflicts with the level graphics, though; the above video is what they look like once those conflicts are resolved.

Monday, August 7, 2017

Garbage in, garbage out

New to Sonic 3 are various different kinds of objects which take over Sonic's movement and rotate him using a series of 3D rotation sprites. Rather than do this through animations, these objects modify Sonic's mapping frame directly so he's always in the correct pose, depending on his distance to the center of the object.

Should you enter debug mode while riding one of these objects however, everything catches on fire pretty quickly, with some emulators outputting garbage sprites to the screen, where as other emulators, as well as hardware, mysteriously reset back to the SEGA screen.

So what's going on? Well, right off the bat, when you enter object placement mode, the first thing that happens is that the player character's mappings and art pointer are backed up to somewhere else in RAM.
    moveq   #0,d0
    move.b  (Debug_placement_mode).w,d0
    move.w  off_92A1C(pc,d0.w),d1
    jmp     off_92A1C(pc,d1.w)
; ---------------------------------------------------------------------------
off_92A1C:  dc.w loc_92A20-off_92A1C
            dc.w loc_92AB0-off_92A1C
; ---------------------------------------------------------------------------

    addq.b  #2,(Debug_placement_mode).w
    move.l  $C(a0),($FFFFFFCA).w        ; save mappings to $FFCA
    cmpi.b  #6,5(a0)
    bhs.s   loc_92A38
    move.w  $A(a0),($FFFFFFCE).w        ; save art tile to $FFCE

The reason for this is that, in order to display the preview sprite for the currently selected object, the player's mappings and art are temporarily replaced with those of the target object. The player's mapping frame is also changed in order to pick the appropriate sprite from the sprite mapping definitions.

If this isn't setting off alarm bells already, it should. Remember how the 3D rotation objects would rather mess with the player's mapping frame directly? Well, the Carnival Night Zone barrel above, as an example, animates Sonic by setting his mapping frame to a value between $55 and $5B. These correspond to the standing rotation sprites as seen below.

Ordinarily, what happens is that a mapping frame of $55 gets multiplied by two, and the result, $AA, is added to Sonic's base mappings address in order to retrieve the pointer to the actual mapping definition for mapping frame $55.

However, when we enter object placement mode, Sonic's mappings pointer is replaced with the pointer for the currently selected object, for instance, a ring. Now, a ring has nine whole frames of animation, so when we add $AA to the base mappings address for rings at $1A99A, we end up with the value $1AA44, which is well past all the mapping definitions and their pointers, and smack dab in the middle of the code for the rings from the slot machine bonus cage.
Map_Ring:   dc.w word_1A9AC-Map_Ring    ; 1A99A: 0012
            dc.w word_1A9B4-Map_Ring    ; 1A99C: 001A
            dc.w word_1A9BC-Map_Ring    ; 1A99E: 0022
            dc.w word_1A9C4-Map_Ring    ; 1A9A0: 002A
            dc.w word_1A9CC-Map_Ring    ; 1A9A2: 0032
            dc.w word_1A9D4-Map_Ring    ; 1A9A4: 003A
            dc.w word_1A9DC-Map_Ring    ; 1A9A6: 0042
            dc.w word_1A9E4-Map_Ring    ; 1A9A8: 004A
            dc.w word_1A9EC-Map_Ring    ; 1A9AA: 0052
word_1A9AC: dc.w 1
            dc.b $F8,   5,   0,   0, $FF, $F8
word_1A9B4: dc.w 1
            dc.b $F8,   5,   0,   4, $FF, $F8
word_1A9BC: dc.w 1
            dc.b $F8,   1,   0,   8, $FF, $FC
word_1A9C4: dc.w 1
            dc.b $F8,   5,   8,   4, $FF, $F8
word_1A9CC: dc.w 1
            dc.b $F8,   5,   0,  $A, $FF, $F8
word_1A9D4: dc.w 1
            dc.b $F8,   5, $18,  $A, $FF, $F8
word_1A9DC: dc.w 1
            dc.b $F8,   5,   8,  $A, $FF, $F8
word_1A9E4: dc.w 1
            dc.b $F8,   5, $10,  $A, $FF, $F8
word_1A9EC: dc.w 0
; ---------------------------------------------------------------------------

    moveq   #0,d0                       ; 1A9EE: 7000
    move.b  5(a0),d0                    ; 1A9F0: 1028 0005
    move.w  off_1A9FC(pc,d0.w),d1       ; 1A9F4: 323B 0006
    jmp     off_1A9FC(pc,d1.w)          ; 1A9F8: 4EFB 1002
; ---------------------------------------------------------------------------
off_1A9FC:  dc.w loc_1AA02-off_1A9FC    ; 1A9FC: 0006
            dc.w loc_1AA56-off_1A9FC    ; 1A9FE: 005A
            dc.w loc_1AA62-off_1A9FC    ; 1AA00: 0066
; ---------------------------------------------------------------------------

    moveq   #0,d1                       ; 1AA02: 7200
    move.w  $3C(a0),d1                  ; 1AA04: 3228 003C
    swap    d1                          ; 1AA08: 4841
    move.l  $34(a0),d0                  ; 1AA0A: 2028 0034
    sub.l   d1,d0                       ; 1AA0E: 9081
    asr.l   #4,d0                       ; 1AA10: E880
    sub.l   d0,$34(a0)                  ; 1AA12: 91A8 0034
    move.w  $34(a0),$10(a0)             ; 1AA16: 3168 0034 0010
    moveq   #0,d1                       ; 1AA1C: 7200
    move.w  $3E(a0),d1                  ; 1AA1E: 3228 003E
    swap    d1                          ; 1AA22: 4841
    move.l  $38(a0),d0                  ; 1AA24: 2028 0038
    sub.l   d1,d0                       ; 1AA28: 9081
    asr.l   #4,d0                       ; 1AA2A: E880
    sub.l   d0,$38(a0)                  ; 1AA2C: 91A8 0038
    move.w  $38(a0),$14(a0)             ; 1AA30: 3168 0038 0014
    lea     Ani_Ring(pc),a1             ; 1AA36: 43FA 002E
    bsr.w   Animate_Sprite              ; 1AA3A: 6100 01AC
    subq.w  #1,$40(a0)                  ; 1AA3E: 5368 0040
    bne.w   Draw_Sprite                 ; 1AA42: 6600 0182      <---------
    movea.l $2E(a0),a1                  ; 1AA46: 2268 002E
    subq.w  #1,(a1)                     ; 1AA4A: 5351
    bsr.w   GiveRing                    ; 1AA4C: 6100 FB48
We're now trying to read garbage information as mappings data, which explains the digital vomit seen in emulators, but in reality, it's even worse. The word value at $1AA44 is 0001 which, when added to $1A99A, results in $1A99B, an odd address. Odd not as in peculiar, but indivisible by 2.
    movea.l mappings(a0),a1
    moveq   #0,d4
    btst    #5,d6                       ; is the static mappings flag set?
    bne.s   loc_1ADD8                   ; if it is, branch
    move.b  mapping_frame(a0),d4
    add.w   d4,d4
    adda.w  (a1,d4.w),a1
    move.w  (a1)+,d4                    ; <--------- <--------- <---------
    subq.w  #1,d4                       ; get number of pieces
The Render_Sprites function proceeds to read the sprite piece count from address $1A99B. This results in an attempt to read a word value from an odd address, which is an illegal operation on the 68000 processor. An exception is raised, which is caught by Sonic 3's exception handler, and the game resets in response to the unexpected catastrophe.

Emulators which ignore this hardware rule in favor of increased performance don't raise this exception, so the garbage information is successfully read, resulting in garbage sprites being displayed on screen.