Note: assembly syntax is WLA-DX for SNES code, and RGBDS for GB code

LLE Detection

Some quality GB emulators implement SGB HLE (High Level Emulation), mostly for fancy borders. In the event you want to have SGB-specific capability (for example, OBJ_TRN), you may want a fallback for emulators that do SGB HLE that won't be able to run custom SNES code.

The packets we can rely on for LLE detection include DATA_SND, DATA_TRN and JUMP. The GB can only receive data from the SNES via rP1, and even MLT_REQ can be HLE'd, so we'll send custom SNES code over and potentially jump to it.

Sending custom Player 2 inputs

  1. Send a MLT_REQ packet for 2 players
  2. Send the following patch (before inputs are sent from SNES to GB, this hook is run):
.org $808

.accu 8
.index 8

PreGBMainLoopHook:
    lda #$12
    sta $006005.l
    rts

As a DATA_SND packet:

    db ($0f<<3)|1, $08,$08,$00, $07, $a9,$12,$8f,$05,$60,$00,$60
  1. A typical PollInput routine will cpl and swap the value to be $de. Or if you don't want to waste cycles having it manipulated, don't cpl Player 2's input, selecting dpad in rP1 will give $2 in its low nybble and selecting the face buttons in rP1 will give $1 in its low nybble.

SNES<->GB comms and hooks

SGB to GB comms

  • The SNES can only communicate to the GB by sending a byte to the ICD2P_REGS ($006004-$006007 for players 1-4)
  • The GB will receive the data's high nybble when getting the face buttons (A/B/Start/Select), and the low nybble when getting the direction buttons.

Note that the common method of polling input will cpl these nybbles and reverse the nybbles they arrived in, ie the face buttons are saved in a ram var's low nybble.

GB to SGB comms

  • The main method is through SGB commands like DATA_SND and JUMP
  • The other method is that GB's screen is read and displayed every frame by the SGB, by being copied through some ram buffers. You can put important info there if you have custom SNES code that can read what's in those buffers.

Note that you will need to send an appropriate MASK_EN SGB command if you don't want players to see your corrupted screen data, or if your custom data is to be read while the game is being displayed, you will need to run custom SNES code to alter SNES' BG3 tilemap to hide it.

Hooks

Using DATA_SND, you can configure the following hooks

  • $000800 - This is run just before processing the bytes received for a 1+ packet command. The ram var $0002c2 contains the command ID. For example, $18 for OBJ_TRN
  • $000808 - This is run at the start of the SNES' inner GB main loop
  • $000810 - This is run at the end of the SNES' inner GB main loop
  • $000818 - This is run in misc scenarios, where A identifies the specific scenario
    • A==0 - runs soon after BIOS starts. Potentially unusable as a hook?
    • A==1 - runs when opening the controls submenu
    • A==2 - runs when opening the SGB menu
    • A==3 - runs sometime after the main GB loop. Might be skipped if keys were held, and it wasn't a button combo?
    • A==4 - runs sometime before the main GB loop

Here is the GB main loop for reference. The addresses are for version 0 of the BIOS. For other versions, subtract 3 from the addresses.

DoMainGBLoop:
    jsr wPreGBMainLoopHook                       ; $baa7 : $20, $08, $08
    jsr SendInputsToGB                           ; $baaa : $20, $7f, $bc
    jsr UpdateAttractMode                        ; $baad : $20, $2c, $bd
    jsr WriteToSPCs4ports                        ; $bab0 : $20, $ba, $ba
    jsr TryHandlingAnSGBPacket                   ; $bab3 : $20, $d9, $bb
    jsr TryHandlingAnSGBPacket                   ; $bab6 : $20, $d9, $bb
    jsr wPostGBMainLoopHook                      ; $bab9 : $20, $10, $08
    rts                                          ; $babc : $60

OBJ_TRN

Considerations:

  • Firstly, you need to send the patch to allow use of OBJ_TRN as it is stubbed out in the SGB BIOS
  • Make sure to send OBJ_TRN before PCT_TRN. This will ensure the border fading in does not clear OBJ_TRNs palettes
  • You may want to prevent use of the SGB menu which will overwrite OAM tile data, and set OBJ palettes
  • The last row of tilemap will be whited out. If you have a border, cover that last row. If not, make sure your game melds well with the white (make most of your backgrounds white/have a bottom status bar/etc)
  • Use this guide to see how setting up GB tile data and tilemap for the last GB row affects your custom SNES objects

Patch: allowing use of OBJ_TRN

Uses SNES RAM from $800 to $802, and $900 to $915

    db ($0f<<3)|1, $00,$09,$00, $0b, $ad,$c2,$02,$c9,$18,$d0,$0e,$af,$db,$ff,$00
    db ($0f<<3)|1, $0b,$09,$00, $0b, $c9,$00,$f0,$03,$4c,$25,$c9,$4c,$28,$c9,$60
    db ($0f<<3)|1, $00,$08,$00, $03, $4c,$00,$09

Patch: disabling the SGB menu

Uses SNES RAM from $818 to $826

    db ($0f<<3)|1, $18,$08,$00, $0b, $c9,$03,$d0,$0a,$a9,$28,$8d,$43,$0c,$68,$68
    db ($0f<<3)|1, $23,$08,$00, $04, $4c,$f6,$ce,$60

Sending tile data for OBJ_TRN

; `DATA_TRN` to $7eb000 (OBJ tile data buffer)
    db ($10<<3)|1, $00,$b0,$7e

; `DATA_SND` 2 bytes to $0211, to update vram $a000-$afff (replace last byte with 3 to update vram $b000-$bfff)
    db ($0f<<3)|1, $11,$02,$00, $02, $01,$02

; `DATA_SND` a byte for the NMI vector to DMA the data
    db ($0f<<3)|1, $17,$02,$00, $01, $01

Using the SNES mouse

Note: this needs work. The cursor tile data and palettes need loading, if this is to load immediately, but there should also be some kind of "mouse is connected" flag for the GB.

  1. Send a MLT_REQ for 4 players.

  2. Send the following DATA_SND packets:

	db ($0f<<3)|1, $00,$09,$00, $0b, $4b,$f4,$0a,$09,$f4,$f3,$d7,$5c,$fb,$d7,$01
	db ($0f<<3)|1, $0b,$09,$00, $0b, $20,$b0,$d1,$20,$d5,$cf,$ad,$21,$0c,$8f,$05
	db ($0f<<3)|1, $16,$09,$00, $0b, $60,$00,$ad,$22,$0c,$8f,$06,$60,$00,$ad,$3b
	db ($0f<<3)|1, $21,$09,$00, $0b, $0f,$8f,$07,$60,$00,$a2,$00,$af,$db,$ff,$00
	db ($0f<<3)|1, $2c,$09,$00, $0b, $f0,$08,$20,$a0,$bc,$68,$68,$4c,$aa,$ba,$20
	db ($0f<<3)|1, $37,$09,$00, $07, $a3,$bc,$68,$68,$4c,$ad,$ba, $00,$00,$00,$00
    db ($0f<<3)|1, $08,$08,$00, $03, $4c,$00,$09
  1. If using a typical GB PollInput routine, cpl and swap P2 to P4's result
  2. P2 will have the mouse X in the SNES screen, P3 will have the mouse Y, and P4 will have bit 0 = left button clicked, bit 1 = right button clicked
  3. For GB screen offsets, subtract $30 from mouse X, and $28 from mouse Y

OBJ_TRN

OBJ_TRN is one of the documented packet commands that can be sent to the SGB, but was not used in commercial games, and was even stubbed in the SGB BIOS (its handler just has rts).

It allowed you to display custom SNES-quality 4bpp sprites without writing SNES code. You would just write SNES OAM data to Gameboy VRAM, and the SGB BIOS would pick it up and transform it into SNES sprites.

In order to not show artifacts due to non-graphical data appearing on the screen, OBJ_TRN would specifically hide the last tilemap row, so that's where you would put your SNES OAM data.

It had conflicts with some other SGB capability:

  • Generic palette fading - usually when fading happens (borders/certain sub-menus/etc), all palettes are faded. OBJ_TRN lets you set OBJ palettes that were sent via PAL_TRN, but these are overridden if sent during a generic SGB fade out/in. We can avoid most border fades by disabling the SGB menu, and for PCT_TRN borders, we simply need to send OBJ_TRN before PCT_TRN; a branch in BIOS code will then prevent updating any OBJ palettes.

  • OAM update code - when the SGB needs to display sprites (menu cursor/attract mode/etc), the SGB BIOS will run its OAM update code in place of OBJ_TRN's specific update code. These scenarios also override OAM tile data.

Preventing conflicts

The conflicts could be prevented by disallowing use of the SGB menu, though there was no official way to do this. The following relevant code, run in a loop, in the BIOS might shine a light on how WE could do this:

    jsr CheckShouldOpenSGBMenu                   ; $cee0 : $20, $06, $cf
    jsr JmpDmaTransferNewGBScreenRows            ; $cee3 : $20, $90, $ff
    jsl TryCheckingUnlocksBtnsState              ; $cee6 : $22, $7d, $dd, $01
    lda #$03                                     ; $ceea : $a9, $03
    jsr wMiscSGBEventsHook                       ; $ceec : $20, $18, $08
    jsr JmpDmaTransferNewGBScreenRows            ; $ceef : $20, $90, $ff
    jsl UpdateFramesHeldRL                       ; $cef2 : $22, $b8, $d9, $01
    jsr JmpDmaTransferNewGBScreenRows            ; $cef6 : $20, $90, $ff

...

CheckShouldOpenSGBMenu:
; Jump away if we've held L and/or R too long
    lda wFramesHeldP1JoyRL                       ; $cf06 : $ad, $43, $0c
    cmp #$28                                     ; $cf09 : $c9, $28
    beq @checkP2                                 ; $cf0b : $f0, $20

; Jump away if a non-LR button is also held
    lda wJoy1High                                ; $cf0d : $ad, $12, $0f
    bne @checkP2                                 ; $cf10 : $d0, $1b

    lda wJoy1Low                                 ; $cf12 : $ad, $11, $0f
    and #$f0                                     ; $cf15 : $29, $f0
    cmp #JOYF_L|JOYF_R                           ; $cf17 : $c9, $30
    bne @checkP2                                 ; $cf19 : $d0, $12

; Handle SGB menu
    stz wSGBMenuCursorController                 ; $cf1b : $9c, $1f, $0c
    lda #$01                                     ; $cf1e : $a9, $01
    sta wInSGBMainMenuWithMainGamepad            ; $cf20 : $8d, $01, $0f
    jsr handleSGBMainMenu                        ; $cf23 : $20, $ee, $d0
...
@checkP2:

...

UpdateFramesHeldRL:
; If any of L and R are held, +1 to wFramesHeldP1JoyRL
; When both are released, clear it
    lda wJoy1Low                                 ; $d9b8 : $ad, $11, $0f
    and #JOYF_L|JOYF_R                           ; $d9bb : $29, $30
    bne @incP1JoyheldFrames                      ; $d9bd : $d0, $05

    stz wFramesHeldP1JoyRL                       ; $d9bf : $9c, $43, $0c
    bra @afterP1Joy                              ; $d9c2 : $80, $0b

@incP1JoyheldFrames:
; wFramesHeldP1JoyRL maxes out at $28
    lda wFramesHeldP1JoyRL                       ; $d9c4 : $ad, $43, $0c
    ina                                          ; $d9c7 : $1a
    cmp #$29                                     ; $d9c8 : $c9, $29
    beq @afterP1Joy                              ; $d9ca : $f0, $03

    sta wFramesHeldP1JoyRL                       ; $d9cc : $8d, $43, $0c

@afterP1Joy:

What's happening here? wFramesHeldP1JoyRL will increment from $00 to $28 whenever either L or R is held. CheckShouldOpenSGBMenu will call handleSGBMainMenu (which handles opening the menu, and other functionality nested in it) when the counter hasn't yet reached $28, but both L and R is held.

What is the relevance of the counter? Well, to open the menu L and R must be held around the same time. In the case that someone holds L for a few seconds, then holds R, the counter will have already maxed out preventing opening the menu.

You might think the solution would be to just set the counter to $28 in wMiscSGBEventsHook, but if L and R are held on the same frame, UpdateFramesHeldRL would clear the counter, then in the next loop of the above code, CheckShouldOpenSGBMenu would see a counter of 0 with both buttons held, and open the menu.

Instead, we can both set the counter, and jump over UpdateFramesHeldRL as it is only used for the SGB menu, by DATA_SNDing the following:

.org $818

; A - misc event
.accu 8
.index 8
MiscSGBEventsHook:
    cmp #$03
    bne @done

    lda #$28
    sta wFramesHeldP1JoyRL  
    pla
    pla
    jmp $cef6 ; address of the last `JmpDmaTransferNewGBScreenRows` in the loop code

@done:
    rts

Patching OBJ_TRN

So now the menu can't be opened to further screw things. We still can't use OBJ_TRN because it just runs rts:

; Byte  Content
; 0     Command*8+Length (fixed length=1)
; 1     Control Bits
;         Bit 0   - SNES OBJ Mode enable (0=Cancel, 1=Enable)
;         Bit 1   - Change OBJ Color     (0=No, 1=Use definitions below)
;         Bit 2-7 - Not used (zero)
; 2-3   System Color Palette Number for OBJ Palette 4 (0-511)
; 4-5   System Color Palette Number for OBJ Palette 5 (0-511)
; 6-7   System Color Palette Number for OBJ Palette 6 (0-511)
; 8-9   System Color Palette Number for OBJ Palette 7 (0-511)
;         These color entries are ignored if above Control Bit 1 is zero.
;         Because each OBJ palette consists of 16 colors, four system
;         palette entries (of 4 colors each) are transferred into each
;         OBJ palette. The system palette numbers are not required to be
;         aligned to a multiple of four, and will wrap to palette number
;         0 when exceeding 511. For example, a value of 511 would copy
;         system palettes 511, 0, 1, 2 to the SNES OBJ palette.
; A-F   Not used (zero)
CMD_OBJ_TRN:
    rts                                          ; $c927 : $60


; unused
_CMD_OBJ_TRN:
; If bit 0 clear, cancel OBJ mode, else enable it
    lda wSGBPacketsData  +1                      ; $c928 : $ad, $01, $06
...

As you can see, there is actually code for this command 1 byte away. So the 1st DATA_SND patch we need to do is detect when OBJ_TRN is being sent, and jump to the correct handler. The addresses above are SGB BIOS version 0 addresses. The other versions use $c924/$c925.

The handler then sets a boolean flag to say it's in 'obj mode', changes BG3 to hide the last GB tilemap row, updates palettes as described in packet description, and some other minor flags to prevent corruption by attract mode/screen paint mode.

What's missing now is OBJ_TRN tile data. There is actually stubbed-out capability to send OBJ tile data near CHR_TRN:

.index 16
CopyGBscreenDataToObjTrnTileData:
@next8bytes:
; Copy 4 words over
; Bug: X is reset everytime, so only the 1st 8 bytes of OBJ tile data are ever set
    ldx #$0000                                   ; $c777 : $a2, $00, $00
    lda [wGBTileDataRamSrc], Y                   ; $c77a : $b7, $98
    sta wObjTrnOamTileData.l, X                  ; $c77c : $9f, $00, $b0, $7e
    iny                                          ; $c780 : $c8
    iny                                          ; $c781 : $c8
...
; Set that chr trn needs updating, as part of some heavy IRQ updates
.accu 8
.index 8
    sep #ACCU_8|IDX_8                            ; $c7a5 : $e2, $30
    lda #$01                                     ; $c7a7 : $a9, $01
    sta wPendingChrTrnTileDataUpdate             ; $c7a9 : $8d, $11, $02
    sta wHeavyIrqUpdatesPending                  ; $c7ac : $8d, $17, $02
    rts                                          ; $c7af : $60

As you can see, it's unusable, but we know which ram buffer we need to populate, and some other flags we need to set:

  • wPendingChrTrnTileDataUpdate ($0211) - a boolean flag, this must be set to 1, and before:
  • wHeavyIrqUpdatesPending ($0217) - in the NMI vector code, this will update from either a number of large buffers, OR from CHR_TRN tile data

There is another flag we need to set that controls CHR_TRN update's source and dest:

  • wCurrChrTrnTransferDest ($0212) - copied from CHR_TRN's 'Tile Transfer Destination':
; 1     Tile Transfer Destination
;         Bit 0   - Tile Numbers   (0=Tiles 00h-7Fh, 1=Tiles 80h-FFh)
;         Bit 1   - Tile Type      (0=BG Tiles, 1=OBJ Tiles)
;         Bit 2-7 - Not used (zero)

To be specific, it pulls the source and destination like so:

ChrTrnWordIdxedVramDests:
    .dw $0000/2
    .dw $1000/2
    .dw $a000/2
    .dw $b000/2


ChrTrnBufferSrces:
    .table long, byte
    .row wSGBBorderTileData, $00 ; $7e8000
    .row wSGBBorderTileData+$1000, $00 ; $7e9000
    .row wObjTrnOamTileData, $00 ; $7eb000
    .row wObjTrnOamTileData, $00 ; $7eb000

Where vram dest $0000/$1000 is used for BG2 (the border), and $a000/$b000 is used for OAM2 (normally attract mode border objs).

The source is always $7eb000, so if we want $2000 bytes worth of tiles, that's what we need to fill.

So to recap, we need 1 patch, 1 DATA_TRN to send the tile data to $7eb000, and we need to set 3 flags.

The patch will look like:

.org $800

.accu 8
.index 8
PreExecPacketCmdHook:
    jmp _PreExecPacketCmdHook


.org $900

_PreExecPacketCmdHook:
    lda wCurrPacketCmd ; $02c2
    cmp #$18 ; OBJ_TRN's code
    bne @done

; No need for pulling the return address, we can execute the `rts` of the stubbed-out `OBJ_TRN`

    lda $ffdb.l ; cart version
    cmp #$00
    beq @ver0

    jmp $c925

@ver0:
    jmp $c928

@done:
    rts

After filling the GB tilemap with our OBJ tile data, we can then send:

; `DATA_TRN` to $7eb000 (OBJ tile data buffer)
    db ($10<<3)|1, $00,$b0,$7e

; `DATA_SND` 2 bytes to $0211, to update vram $a000-$afff (replace last byte with 3 to update vram $b000-$bfff)
    db ($0f<<3)|1, $11,$02,$00, $02, $01,$02

; `DATA_SND` a byte for the NMI vector to DMA the data
    db ($0f<<3)|1, $17,$02,$00, $01, $01

Donkey Kong (1994)

Pauline's 'Help' voice

A few SGB games make use of sound-related SGB packets. Some that do, will send Kankichi (sound engine used by the SGB) data to SNES APU RAM at $2b00 (music score area) via SOU_TRN, then send a SOUND command to play 1 of the loaded in songs.

Donkey Kong takes it a step further. In bank $0c, address $5ddd (rom offset $61ddd), SOU_TRN data is sent to that music score area that can play the voice sample, and also has some misc Kankichi commands at certain periods to further manipulate the voice sample.

Then later, at address $6438 (rom offset $62438), more data is sent:

    dw $0004 ; size of transfer
    dw $4b08 ; apu dest
    dw $3b00, $46a3

    dw $0006
    dw $4c3c
    db $02, $ff, $e0, $b8, $02, $b0

    dw $0bb0
    dw $3b00
    <$bb0 bytes of sample data>
  • $4b08 - There are 63 DIR entries for samples and their loop points at $4b00. $4b08 is the entry for sample 2, which sets its address to $3b00
  • $4c3c - There is data for 63 instruments at $4c30, which determines sample to use, adsr1/adsr2/gain values and a pitch base multiplier
  • $3b00 - This is sample 2 data: the voice sample encoded in BRR format.

Space Invaders

What people usually understand about how Space Invaders works is that a ton of data is sent to SNES RAM via DATA_TRN, then a JUMP command is issued to that area so that, essentially, you are playing a SNES game.

There are a couple other interesting details:

DATA_TRN patch

DATA_SND packets are used to setup a hook that runs before SGB packets are handled, and that hook completely replaces DATA_TRN so that it can be faster.

.org $a00

_PreExecPacketCmdHook:
; Only override DATA_TRN
    lda wCurrPacketCmd                           ; $0a00 : $ad, $c2, $02
    cmp #CMD_DATA_TRN                            ; $0a03 : $c9, $10
    bne @done                                    ; $0a05 : $d0, $4c

; Signal to GB that DATA_TRN is starting
    lda #$01                                     ; $0a07 : $a9, $01
    sta ICD2P_REGS.l                             ; $0a09 : $8f, $04, $60, $00

; Do normal DATA_TRN, starting with setting the dest addr of the vram data
    lda wSGBPacketsData+1                        ; $0a0d : $ad, $01, $06
    sta wDataSendDestAddr.b                      ; $0a10 : $85, $b0
    lda wSGBPacketsData+2                        ; $0a12 : $ad, $02, $06
    sta wDataSendDestAddr.b+1                    ; $0a15 : $85, $b1
    lda wSGBPacketsData+3                        ; $0a17 : $ad, $03, $06
    sta wDataSendDestAddr.b+2                    ; $0a1a : $85, $b2

; Replicate DMA transferring the GB screen, catering to SGB BIOS versions
    lda CART_VERSION.l                           ; $0a1c : $af, $db, $ff, $00
    beq @ver0                                    ; $0a20 : $f0, $05

    jsr DmaTransferAGBScreen_nonVer0             ; $0a22 : $20, $8d, $c5
    bra +                                        ; $0a25 : $80, $03

@ver0:
    jsr DmaTransferAGBScreen_ver0                ; $0a27 : $20, $90, $c5

; Signal to GB that we've loaded the screen, so it can load new data, while
; we're doing the mem copy below
+   lda #$00                                     ; $0a2a : $a9, $00
    sta ICD2P_REGS.l                             ; $0a2c : $8f, $04, $60, $00

; Set the GB screen's ram buffer as the src pointer
    lda wCurrPtrGBTileDataBuffer                 ; $0a30 : $ad, $84, $02
    sta wGBTileDataRamSrc.b                      ; $0a33 : $85, $98
    lda wCurrPtrGBTileDataBuffer+1               ; $0a35 : $ad, $85, $02
    sta wGBTileDataRamSrc.b+1                    ; $0a38 : $85, $99
    lda #:wGBTileData0.b                         ; $0a3a : $a9, $7e
    sta wGBTileDataRamSrc.b+2                    ; $0a3c : $85, $9a

; Copy over the $1000 screen bytes
    setaxy16                                     ; $0a3e : $c2, $30
    ldx #$0800                                   ; $0a40 : $a2, $00, $08
    ldy #$0000                                   ; $0a43 : $a0, $00, $00

@nextWord:
    lda [wGBTileDataRamSrc], Y                   ; $0a46 : $b7, $98
    sta [wDataSendDestAddr], Y                   ; $0a48 : $97, $b0
    iny                                          ; $0a4a : $c8
    iny                                          ; $0a4b : $c8
    dex                                          ; $0a4c : $ca
    bne @nextWord                                ; $0a4d : $d0, $f7

; Skip doing the original DATA_TRN
    setaxy8                                      ; $0a4f : $e2, $30
    pla                                          ; $0a51 : $68
    pla                                          ; $0a52 : $68

@done:
    rts                                          ; $0a53 : $60

In short:

  • In the replacement DATA_TRN, $01 is sent to GB's player 1 input
  • The GB screen's data is copied to a generic ram buffer, due to using the generic routine DmaTransferAGBScreen
  • After the GB's screen data is loaded in, $00 is sent to GB's player 1 input, which will signal it to do whatever it wants, for example, load a new screen for the next DATA_TRN
  • While GB is loading a new screen, SNES is memcopying from the generic screen data buffer to the actual desired DATA_TRN destination

From the GB side, those values go through a PollInput routine, whose normal function cpl and swap the values to be $ef and $ff respectively:

DataTrn1000hBankBytes:

...

; The DATA_TRN patch will send 1 when DATA_TRN will load the VRAM tile data...
:   call PollInput                               ; $32f4 : $cd, $6c, $10
    ld a, [wBtnsHeld]                            ; $32f7 : $fa, $51, $d7
    cp $ef                                       ; $32fa : $fe, $ef
    jr nz, :-                                    ; $32fc : $20, $f6

; Then 0 once that vram tile data has all been read
:   call PollInput                               ; $32fe : $cd, $6c, $10
    ld a, [wBtnsHeld]                            ; $3301 : $fa, $51, $d7
    cp $ff                                       ; $3304 : $fe, $ff
    jr nz, :-                                    ; $3306 : $20, $f6

GB is still active

The GB is still running even after a JUMP packet is issued. The data needed for the SNES game to fully function is larger than the free ram areas of bank $7e and $7f. The new sound engine, for example, and all of its data, take up around $cb00 bytes in total. So the GB will DATA_TRN in the background as needed by the SNES game, dependent on special values that are sent to player 1's input.

HandleArcadeMode:

...

.mainLoop:
; Get buttons sent by SNES
    call PollInput                               ; $320c : $cd, $6c, $10
    ld a, [wLastSnesBtnsHeld]                    ; $320f : $fa, $67, $d7
    ld b, a                                      ; $3212 : $47
    ld a, [wBtnsHeld]                            ; $3213 : $fa, $51, $d7

; Wait until a new code has been sent
    cp b                                         ; $3216 : $b8
    jr z, .mainLoop                              ; $3217 : $28, $f3

; Check if SNES sends $3f
    cp $0c                                       ; $3219 : $fe, $0c
    jr z, .code3Fh                               ; $321b : $28, $1c

; Check if SNES sends $2f
    ld hl, DataTrnBanks_3                        ; $321d : $21, $4e, $33
    cp $0d                                       ; $3220 : $fe, $0d
    jr z, .code1FhOr2Fh                          ; $3222 : $28, $07

; Check if SNES sends $1f
    ld hl, DataTrnBanks_2                        ; $3224 : $21, $4a, $33
    cp $0e                                       ; $3227 : $fe, $0e
    jr nz, .mainLoop                             ; $3229 : $20, $e1

.code1FhOr2Fh:
    ld [wLastSnesBtnsHeld], a                    ; $322b : $ea, $67, $d7

; DATA_TRN banks in HL, based on if $1f or $2f sent
    call DataTrnSomeBanks                        ; $322e : $cd, $4a, $32

; 7f:2006 jumps to the main handler for the SNES game, past some init code
    ld hl, Packet_JUMP_7f2006h                   ; $3231 : $21, $c0, $41
    call SendSGBPacketBank3                      ; $3234 : $cd, $a8, $0e
    jr .mainLoop                                 ; $3237 : $18, $d3

.code3Fh:
    ld [wLastSnesBtnsHeld], a                    ; $3239 : $ea, $67, $d7

; DATA_TRN banks in DataTrnBanks_1
    ld hl, DataTrnBanks_1                        ; $323c : $21, $43, $33
    call DataTrnSomeBanks                        ; $323f : $cd, $4a, $32

; 7f:2000 jumps to the main handler for the SNES game
    ld hl, Packet_JUMP_7f2000h                   ; $3242 : $21, $b0, $41
    call SendSGBPacketBank3                      ; $3245 : $cd, $a8, $0e
    jr .mainLoop                                 ; $3248 : $18, $c2

There is a DataTrnBanks_0 sent before this main loop. This loads in Space Invaders' sound engine and data, then sends the code $3f so that DataTrnBanks_1 can be loaded in.