An in-depth look at Link’s Awakening render loop

30 juin 2019

This article is part of an ongoing “Disassembling Link’s Awakening” series, where I attempt to gain some understanding on how special effects where implemented in this game.

We’ve seen previously how Link’s Awakening renders the opening cutscene. This time, let’s take a step back, generalize, and have a look at the main render loop.

The game’s core

Games are usually based on a main loop. Conceptually, it looks like this:

while (true) { // loop forever
  processInput();
  updateGameState();
  renderFrame();
  waitForNextFrame();
}

In plain text, the main loop repeats the same operations, once per frame, over and over:

Of course this is a simplification, and a few key elements have been omitted (for instance there is no audio there). For more information, you can read this much more extensive article about the render loops generic structure.

Link’s Awakening makes no exception, and has its own render loop, right after the initialization code. Let’s see how it is handled.

At the start

The first thing the loop does is to set a flag that signals that the graphic hardware just rendered a new frame — and that we are about to start a new loop. This will turn useful later.

RenderLoop::
    ; Set DidRenderFrame
    ld   a, 1
    ld   [hDidRenderFrame], a

Scrolling the background

We have seen that the Game Boy can display a scrollable tiled Background – which is often the basis for the rendering.

The Game Boy graphic hardware will read the scroll position of the background from specific memory addresses: $FF43 for the X value, and $FF42 for the Y value. These locations are often referenced as rSCX and rSCY (for ScrollX and ScrollY). Writing into these values will change the position of the background window. Simple enough.

However, when the game wants to change the background scroll position, it often needs to compose several values together. In order to do this, the game defines several indirections:

The game can then compose these values during the main render loop (or the during the HBlank period) to apply various effects.

There is also a special mode, controlled by a flag at the $C500 address, which alternate the scroll position between 0 and 128 every other frame (I couldn’t yet understand when this effect is used though).

Here is the relevant code. First, the section for handling the vertical scroll position.

    ; Set ScrollY

    ; Special case for $C500 == 1 (alternate background position)
    ; If $C500 != 0...
    ld   a, [$C500]
    and  a
    jr   z, .applyRegularScrollYOffset
    ; and GameplayType == OVERWORLD...
    ld   a, [WR1_GameplayType]
    cp   GAMEPLAY_OVERWORLD
    jr   nz, .applyRegularScrollYOffset
    ; set scroll Y to $00 or $80 alternatively every other frame.
    ld   a, [hFrameCounter]
    rrca
    and  $80
    jr   .setScrollY

.applyRegularScrollYOffset
    ; Regular case: add the base offset and the screen shake offset
    ld   hl, WR0_ScreenShakeVertical
    ld   a, [hBaseScrollY]
    add  a, [hl]

.setScrollY
    ; Write the computed value into the reference hardware address
    ld   [rSCY], a

And just next comes the code for the horizontal scroll position. This one doesn’t supports as many effects, and is simpler to read.

    ; Set ScrollX

    ; Add the base offset and the screen shake offset
    ld   a, [hBaseScrollX]
    ld   hl, WR0_ScreenShakeHorizontal
    add  a, [hl]
    ; Also add another offset (purpose unknown for now)
    ld   hl, $C1BF
    add  a, [hl]
    ; Write the computed value into the reference hardware address
    ld   [rSCX], a

Loading new data

Now the render loops splits into two main paths. The code will either:

The first path is for loading new data. This mode is only used while the LCD screen is turned off. In this mode the code won’t actually render anything: it will just load the required resources, and wait for the next frame to render a new frame properly.

    ; Parting of the ways

    ; If there are Tiles or Background Maps data to load,
    ; load new data and return.
    ld   a, [wTileMapToLoad]
    and  a
    jr   nz, RenderLoopLoadNewMap
    ld   a, [wBGMapToLoad]
    cp   $00
    jr   z, RenderFrame

In this data-loading path, the code will:

; Data loading path

RenderLoopLoadNewMap::
    ; Control audio during the transition
    ld   a, [WR1_GameplayType]
    cp   GAMEPLAY_MARIN_BEACH
    jr   z, .playAudioStep
    cp   GAMEPLAY_FILE_SAVE
    jr   c, .playAudioStep
    cp   GAMEPLAY_OVERWORLD
    jr   nz, .skipAudio
    ; GameplayType == OVERWORLD
    ld   a, [WR1_GameplaySubtype]
    cp   GAMEPLAY_WORLD_DEFAULT
    jr   nc, .skipAudio

.playAudioStep
    call PlayAudioStep
    call PlayAudioStep
.skipAudio

    ; Load new map tiles and background
    di   ; disable interrupts
    call LoadMapData
    ei   ; re-enable interrupts

    ; Play more audio
    call PlayAudioStep
    call PlayAudioStep

    ; Jump to the end of the render loop
    jp   WaitForNextFrame

And that’s all for the data-loading path.

Rendering a standard frame

If no additional data need to be loaded, we can render an actual frame.

First thing the game will do is to ensure the LCD screen flags are in a consistent state.

The rLCDC (for LCD Control) memory location is composed of several flags that control the behavior of the LCD screen:

When the game needs to manipulate the LCD Control flags, it actually writes into the intermediary variable WR1_LCDControl. This variable is then reported in the actual rLCDC memory during VBlank.

Note that the code will always set the 7th bit of rLCDC to 1, whatever the WR1_LCDControl specifies. This is probably a safeguard: the game never needs to actually shut down the screen, and is it a touchy operation that can damage the hardware under some circonstances – so it was disabled outright.

RenderFrame::
    ; Update LCD status flags

    ; Load the LCD Control flags requested by the game
    ld   a, [WR1_LCDControl]
    ; Discard the 7th bit ("Is LCD screen on or off?")
    and  $7F
    ; Load the actual LCD Control flags
    ld   e, a
    ld   a, [rLCDC]
    ; Set the 7th bit to 1 ("LCD screen is on")
    and  $80
    ; Apply the values extracted from WR1_LCDControl
    or   e
    ; Set the LCD Control flags
    ld   [rLCDC], a

Incrementing the frame counter

The next step is to increment the global frame counter.

As it is stored on a single byte, it will increment up to FF – and then wrap around and start at 00 again.

The global frame counter is used for controlling a lot of effects. For instance:

All these effects look into the frame counter, to see if this is the right time to render an animation.

    ; Increment the global frame counter
    ld   hl, hFrameCounter
    inc  [hl]

Another VBlank hack

The next snippet is a hack for a very specific moment. It is triggered at the end of the Intro sequence, when displaying the “The Legend of Zelda” title logo.

As you may remember from the game, the logo appears with a special scaling effect. The Game Boy is not capable of such scaling effects natively – so like many others, this effect is performed by manipulating the background scroll position while the frame is being rendered.

I won’t enter into details for now — but at least this is why it this snippet needs to be inserted at a very specific place of the render loop (rather than in the dedicated gameplay handler).

    ; Special case for the intro title screen

    ; If GameplayType == INTRO...
    ld   a, [WR1_GameplayType]
    cp   GAMEPLAY_INTRO
    jr   nz, RenderWarpTransition
    ; and the GameplaySubtype is equal or above the title screen...
    ld   a, [WR1_GameplaySubtype]
    cp   $08
    jr   c, RenderWarpTransition
    ; Apply the background scroll manipulations for the logo
    ld   a, $20
    ld   [SelectRomBank_2100], a
    call RenderTitleLogo

Warp effects

The game will often warp Link into a new position, with several visual effects:

This section of the render loop controls the wavy effect of the Dream Shrine and Manbo’s song transition. If such a transition is occurring, the game will render the wave effect – and then jump to the end of the render loop, without further rendering.

As this section of code calls into several unknown functions, I abbreviated it for now.

RenderWarpTransition::
    ; If WarpTransition != 0, render the wavy warp effect
    ld   a, [WR0_WarpTransition]
    and  a
    jp   z, RenderInteractiveFrame

    ; Render the wavy warp effect
    ; (snip)

    ; Jump to the end of the render loop without further rendering
    jp   WaitForNextFrame

Rendering an interactive frame

If we are not rendering a special effect (like the warp) transition, it is now time to render an interactive frame. This means reading the joypad values, and react to the button presses, the passing time, and so on.

First the game will copy some of its low-level working values into the graphics hardware.

RenderInteractiveFrame::
    ; Update graphics registers from game values
    ld   a, [WR1_WindowY]
    ld   [rWY], a
    ld   a, [WR1_BGPalette]
    ld   [rBGP], a
    ld   a, [WR1_OBJ0Palette]
    ld   [rOBP0], a
    ld   a, [WR1_OBJ1Palette]
    ld   [rOBP1], a

Then the current audio track sample gets played.

    call PlayAudioStep

At last we can read the joypad values. This function will read the pressed buttons, and store them into the hPressedButtonsMask variable. This will allow the game to react to joypad changes.

    call ReadJoypadState

Circulez

For some reason, the loading of new tiles is actually done by the VBlank interrupt handler.

This means that if any new tiles need to be swapped in, we must wait for the next VBlank – and thus jump directly to the end of the render loop.

    ; If Background tiles or Ennemies tiles or NPC tiles need to be updated…
    ld   a, [hNeedsUpdatingBGTiles]
    ld   hl, hNeedsUpdatingEnnemiesTiles
    or   [hl]
    ld   hl, WR0_needsUpdatingNPCTiles
    or   [hl]
    ; Jump to the end of the render loop:
    ; the code executed on VBlank interrupt will load the required data.
    jr   nz, WaitForNextFrame

Debug tools

Link’s Awakening developers wrote built-in debug tools during the development of the game. And like many games, to protect against unexpected changes, they didn’t remove the debug tools when shipping the game — but merely disabled them. This means we have access to a wide range of debug tools, if we can find a way to re-enable them.

Fortunately this work has been done before. “The Cutting Room Floor” page on Link’s Awakening debug tools tells us all about enabling and using the build-in debug tools.

The game defines three sets of debug utilities, activated by flags at $0003, $0004 and $0005. If the ROM is edited (or a cheat is used) to set these addresses to a non-zero value, the corresponding debug tools are activated.

The flag at $0003 (named ROM_DebugTool1 in the disassembly) activates the main debug toolset. Some of them are implemented right in the render loop – so the game will first check to see if the debug tools are enabled.

    ; Debug functions

    ; Check if debug mode is enabled (DebugTool1 != 0)
    ld   a, [ROM_DebugTool1]
    and  a
    jr   z, RenderUpdateSprites

The first tool implemented in the Render loop is the Engine freeze. When pressing the Select button, all the rendering is frozen. Animated tiles are static, NPCs and ennemies don’t move — only the music still plays normally. This is a feature probably intended to examine animated frames more easily, and take precise screenshots.

    ; Isn’t engine already paused?
    ld   a, [WR1_EnginePaused]
    and  a
    jr   nz, .engineIsPaused

    ; If any of the directional keys is pressed, don’t attempt to pause the engine.
    ; (This allows using the Select button without enabling Engine freeze.)
    ld   a, [hPressedButtonsMask]
    and  J_RIGHT | J_LEFT | J_UP | J_DOWN
    jr   z, .skipRenderIfEnginePaused

.engineIsPaused
    ; If the Select button isn’t pressed, jump to the end.
    ld   a, [$FFCC]
    and  J_SELECT
    jr   z, .skipRenderIfEnginePaused

    ; If Select button was just pressed, toggle engine paused status.
    ld   a, [WR1_EnginePaused]
    xor  $01
    ld   [WR1_EnginePaused], a

    ; If the engine was just paused, skip the rest of the render loop.
    ; This will bypass animations, AI, etc.
    jr   nz, WaitForNextFrame

When pressing Select again, the engine will resume – but with a twist: Free-Movement Mode is now enabled.

Free-Movement Mode allows Link to move over any wall, pit, water surface, or anything blocking. Additionally, Link will also move faster than normal. This of course allow developers to quickly move from one place to the other, without bothering about having the right set of items to pass over a specific fence.

To exit the Free-Movement mode, press Select twice: once to freeze the engine again, and once to unfreeze it: this will toggle the Free-Movement Mode out.

    ; If the engine was just resumed, toggle Free-movement mode.
    ld   a, [WR0_FreeMovementMode]
    xor  $10
    ld   [WR0_FreeMovementMode], a
    jr   WaitForNextFrame

Note that only the Free Movement switch is written in the render loop: the actual implementation is in the physics engine.

Last, the code will check if the Engine wasn’t paused previously, and skip further rendering if needed.

.skipRenderIfEnginePaused
    ; If the engine is paused, skip the rest of the render loop.
    ; This will bypass animations, AI, etc.
    ld   a, [WR1_EnginePaused]
    and  a
    jr   nz, WaitForNextFrame

If we know the engine wasn’t frozen, it is now time to render some motion.

Preparing sprites

At the beginning of each render loop, before the gameplay code runs, all sprites are initially hidden.

This ensures that only sprites explicitly made visible by the gameplay code will appear on screen. And it makes hiding an NPC or sprite element easy: just don’t explicitly tell it to be visible.

RenderUpdateSprites::
    ; If not in Inventory, initially hide all sprites
    ld   a, [WR1_GameplayType]
    cp   GAMEPLAY_INVENTORY
    jr   nz, .resetSpritesVisibility

    ; If Inventory is actually visible, leave sprites visible
    ld   a, [WR1_GameplaySubtype]
    cp   GAMEPLAY_INVENTORY_DELAY1
    jr   c, RenderGameplay

.resetSpritesVisibility
    callsw HideSprites

Execute gameplay code

This is where real things happens.

RenderGameplay::
    call ExecuteGameplayHandler

Inside this function, the code will do a series of repeated steps: retrieve a variable storing some state in RAM, and dispatch to handlers using a switch-like statement. The state will get more and more specific, like:

ExecuteGameplayHandler: The game is on the introduction cutscene: jump to IntroHandler
    ▸ IntroHandler: The cutscene is at the second part: jump to IntroBeachHandler
        ▸ IntroBeachHandler: Marin is walking slow: animate NPC and background

And so on.

The gameplay code will:

Unfortunately this article is already too long, so details will have to wait for a follow-up article. In the meantime, have a look at the actual code!

At the end of the GameplayHandler, copies of the hardware state are ready to be applied to actual hardware values during the next V-Blank interval.

Update palettes

Once the gameplay code is executed, it may have defined a new color palette to be loaded.

In this case, some Game Boy Color-specific code will handle loading the palette data, from the index defined in wPaletteToLoadForTileMap.

RenderPalettes::
    ; If isGBC…
    ld   a, [hIsGBC]
    and  a
    jr   z, .clearPaletteToLoad
    ; Load palette set defined in wPaletteToLoadForTileMap
    ld   a, $21
    call SwitchBank
    call label_406E

.clearPaletteToLoad
    xor  a
    ld   [wPaletteToLoadForTileMap], a

Render the window submenu

The Game Boy hardware has a notion of “Window”. This is a specific tiled image, that can scroll to overlap partially (or totally) the usual background tiles.

Of course the intended use for the Window is to display HUD elements and status bars in games. And this is exactly what Link’s Awakening uses it for.

During normal gameplay, the Window displays the items and hearts at the bottom of the screen. But when the inventory is visible, the Window overlaps the entire screen.

This stage of the render loop sets the next Window position target, depending on whether the inventory is currently visible or not.

RenderWindow::
    callsw UpdateWindowPosition

At this stage, all steps required for an interactive frame are done.

Waiting for render

The end is near. We’re getting back to the common code path for interactive and non-interactive frame. These are the last steps for preparing the next rendering.

First, the Window position is applied: target position is copied to the hardware registers controlling the window position.

WaitForNextFrame::
    ; Apply target window position
    ld   a, $1F
    call SwitchBank
    call label_7F80

Then the first graphic banks (containing tiled graphics) is enabled.

    ; Switch to first graphics bank ($0C on DMG, $2C on GBC)
    ld   a, $0C
    call AdjustBankNumberForGBC
    call SwitchBank

And last: the flag indicating that a frame was rendered is reset to 0.

    ; Reset didRenderFrame flag
    xor  a
    ld   [hDidRenderFrame], a

Halt

Now our frame is fully ready. We just need to wait for the Game Boy PPU to render it to the screen.

This can take a while–and the game can’t do much about it: while the PPU is rendering the frame, most of the data should not be touched in any way.

So we need to wait for the rendering to be done.

Fortunately, there is a way to wait for this without polling the PPU for some state. We can simply stop the CPU entirely until the frame is rendered.

This is done with the halt instruction. It stops the CPU until the next hardware interrupt. And hopefully, the next interrupt that will be fired will be the VBLANK interrupt, signaling that the rendering is done.

    ; Stop the CPU until the next interrupt
    halt

Hopefully, when the CPU resumes execution of our code, our frame will be fully processed.

However, there are no guarantees. Maybe another interrupt was fired, and we still need to wait. In that case, the game code resorts to polling. The code waits until a flag sets by the VBLANK interrupt is set: this will guarantee that the frame has indeed been rendered.

.pollNeedsRenderingFrame
    ; Loop until hNeedsRenderingFrame != 0
    ld   a, [hNeedsRenderingFrame]
    and  a
    jr   z, .pollNeedsRenderingFrame

Once the frame was rendered, we can clear the flag immediately…

    ; Clear hNeedsRenderingFrame
    xor  a
    ld   [hNeedsRenderingFrame], a

… and start a new interation of the render loop.

    ; Jump to the top of the render loop
    jp   RenderLoop

Phew! We’re done.

Conclusion

Link’s Awakening render loop is a very fine piece of code. Although it runs on the modest Game Boy hardware (underpowered even for its time), cleanliness is not sacrificed to efficiency. It uses the proper indirections when required, like compositing final values from different sources, and does not try to circumvent its render loop (I’m looking at you, Pokémon Red/Blue.)

Link’s Awakening was started as an unofficial side project, to see if an ambitious Zelda game could run on the Game Boy. Which means that Link’s Awakening programmers had all the experience accumulated while making A Link to the Past. This probably helped them to anticipate what was needed in a Zelda game, and explains why the code is nicely structured in all regards.

Interested in knowing more?
Discover more of the code, and join the disassembly team at https://github.com/mojobojo/LADX-Disassembly.

Discussion, liens, et tweets

J’écris des sites web, des logiciels, des applications mobiles. Vous me trouverez essentiellement sur ce blog, mais aussi sur Mastodon, parmi les Codeurs en Liberté, ou en haut d’une colline du nord-est de Paris.