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
- Send a
MLT_REQ
packet for 2 players - 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
- A typical
PollInput
routine willcpl
andswap
the value to be $de. Or if you don't want to waste cycles having it manipulated, don'tcpl
Player 2's input, selecting dpad inrP1
will give$2
in its low nybble and selecting the face buttons inrP1
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
andJUMP
- 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 forOBJ_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, whereA
identifies the specific scenarioA==0
- runs soon after BIOS starts. Potentially unusable as a hook?A==1
- runs when opening the controls submenuA==2
- runs when opening the SGB menuA==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
beforePCT_TRN
. This will ensure the border fading in does not clearOBJ_TRN
s 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.
-
Send a
MLT_REQ
for 4 players. -
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
- If using a typical GB
PollInput
routine,cpl
andswap
P2 to P4's result - 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
- 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 forPCT_TRN
borders, we simply need to sendOBJ_TRN
beforePCT_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_SND
ing 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 fromCHR_TRN
tile data
There is another flag we need to set that controls CHR_TRN
update's source and dest:
wCurrChrTrnTransferDest
($0212) - copied fromCHR_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 63DIR
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.