game.lua and Wide view
Run a script against the game every frame, and describe the world so GBA Duo can paint it beside the picture.
A pack's main.lua feeds the companion screen. A pack can also ship a game.lua that
runs against the game itself, every emulated frame, in its own Lua sandbox (its own globals,
budgets and errors: a broken game.lua never touches the companion screen).
{
"formatVersion": 4,
"gameScript": "game.lua",
...
}gameScript needs formatVersion 4 (older apps refuse the pack; set minAppVersion in the
catalog). The file must exist in the pack. It is part of the pack's fingerprint, so changing it
asks the player again before it may write.
Hooks
All optional. The sandbox is the same as main.lua's (mem, gfx, lz77, log,
pack.require).
| Hook | When | What it's for |
|---|---|---|
init() |
once, after load | set up state. Read-only. |
frame(view) |
after each emulated frame | read and, if allowed, write the game's RAM |
wide(view) |
after each emulated frame, only while Wide view is on | describe the world for the wide margins. Read-only. |
view is a table: frame (the frame counter, frame() only), width (the picture's width in
pixels), left (columns before the game's own 240), and for wide() also match
(0 to 1, how well last frame's world predicted the game's edge columns; handy while calibrating).
Each hook gets 100,000 Lua instructions and 2 ms per call. An error or an overrun skips that
frame's call (adb logcat -s GBADuoScript GBADuoCore EmuThread); five in a row switch game.lua
off until the next game start.
Writing the game's memory from frame()
Same rules as actions (see Changing the game):
"permissions": ["memory-write"], Settings > Packs can change games, and the player's answer
for this pack and game. Until they have answered, frame() runs with writes refused
(mem.write8 and friends return false and a reason). The same RAM-only regions apply.
What differs: at most 256 bytes per frame, and only the first write is logged.
init(), the top level and wide() can never write.
wide(view): painting more of the world
Wide view draws the game's own 240x160 frame in the middle of a wider picture (up to 400 px, set
from the screen) and paints the margins from what wide() describes. Return nil or false
for "no world right now" (battle, menu, unknown map): the margins stay black. Otherwise return a
table, every field an integer (bus addresses are plain numbers):
| Field | Meaning |
|---|---|
x, y |
world pixel of the screen's top-left |
grid, gridW, gridH |
the map: u16 cells, row-major, one 16x16 metatile each |
idMask |
metatile id = cell & idMask |
border, borderW, borderH |
block repeated outside the map |
borderShift (optional, 0) |
the block index of cell c is (c - borderShift) mod size |
undefinedId (optional, none) |
a cell with this id is outside the map: use the border |
metatiles, metatiles2, split |
ids below split read from metatiles, the rest from metatiles2 (16 bytes each) |
tiles |
4bpp tile data, 32 bytes a tile (normally VRAM 0x06000000) |
palette |
16 banks of 16 BGR555 colours (normally 0x05000000) |
A metatile is 8 u16 tile entries: bottom layer TL, TR, BL, BR, then top layer TL, TR, BL, BR. A tile entry: bits 0-9 tile, 10 h-flip, 11 v-flip, 12-15 palette bank. A pixel is the top layer's if that is not colour 0, else the bottom layer's, else the backdrop.
Several layers (layers)
A game whose map is separate grids (e.g. The Minish Cap, Golden Sun) returns layers instead of the
single-grid fields above: { x, y, palette, layers = { bottom, ..., top } } (one to three tables,
bottom first). Each layer has its own grid, its own metatile table and its own tile data:
| Layer field | Meaning |
|---|---|
grid, stride, gridW, gridH |
cells, row-major, stride cells a row, gridW x gridH of them in use |
cellBytes (optional, 2) |
2 for u16 cells, 4 for u32 cells (the id is the cell's low bits, see idMask) |
idMask |
metatile id = cell & idMask |
special (optional, 0) |
a u16 table: a u16 cell >= 0x4000 reads its id from special[cell - 0x4000] |
metatiles |
4 u16 tile entries (TL, TR, BL, BR) per id, 8 bytes each |
tiles |
4bpp tile data for this layer (its BG's character base) |
The top layer's pixel wins unless it is colour 0, then the bottom layer's, else the backdrop
(palette colour 0). Beyond the grids the margins are black. x, y are relative to the grids' origin.
The app checks your answer against the game: the 16 columns on the frame's edges are predicted and compared with what the emulator drew. Below 85% for four frames in a row the margins hide, and they come back after four good ones. So a wrong world looks like "no wide view", not like garbage. NPCs and the player are not part of the world, so they don't appear in the margins.
Turning Wide view on and off
Wide view is a per-game setting the player switches from the pack's companion screen, so give
the layout a button. The app handles the reserved action wideView itself (your Lua is not
involved): "args" is "on", "off" or "toggle" (the default). Every snapshot carries the
state as built-in values: _wide (1 or 0) and _wideLabel (ON, OFF, or N/A when the
pack has no wide()).
{
"type": "box", "class": "wide-pill",
"onClick": { "call": "wideView", "args": ["toggle"] },
"children": [ { "type": "text", "text": "WIDE {_wideLabel}" } ]
}zelda-the-minish-cap-companion/game.lua is the two-layer example (camera and room origin from
gRoomControls, layers from gMapTop / gMapBottom). golden-sun-companion/game.lua is the
three-layer one with u32 cells: one grid in EWRAM holds the three BG layers side by side, so each
layer's grid starts at its block of columns and all three share the same x, y.
pokemon-firered-companion/game.lua is a worked example (FireRed 1.0): it reads the map the
game keeps in RAM (gBackupMapLayout) and the tilesets from the ROM layout. It follows the camera
from the BG scroll registers (mem.u16(0x04000014)), anchored whenever the player stands still,
and keeps following across a map connection: the game rebases the tile coordinates there (the
player's current and previous tile, by the same whole number of tiles) while the scroll carries
on, so the script recovers the rebase from where the camera sits between those two tiles. Set
DEBUG = true at the top to log the frames where the prediction misses (adb logcat -s GBADuoPack).
Drawing the live map on the companion screen
gfx.layers{...} (in main.lua too) draws the same layer description as a picture, so a pack
can show the player an area map: see gfx.layers. Golden Sun's Maps
page uses it, with the player's marker placed on top by the layout.