22 — The Long Way Back
The post-battle result bar worked in theory for weeks. Getting it to work in practice took nine fixes and a lesson in how many things can go wrong between winning a battle and showing a window on screen.
The Setup
The result bar was built a long time ago. A compact window that slides up after a battle win — streak count, shards earned, pity counter, milestone fanfares. The kind of thing that makes a loot loop feel alive instead of silent. It compiled. It had states. The Lua diagnostic scripts ran clean.
It never showed.
Every session, the bar was deferred. Other features went in. The bar sat in the code, waiting. This session, it got the full reckoning.
Root Cause One: The Wild Battle That Wasn’t
First thing: no streak, no shards, no gear — on wild battles. Win a trainer fight and everything works. Walk into tall grass and the game acts like nothing happened.
Dug into HandleEndTurn_BattleWon. Found this:
if (gTrainerBattleOpponent_A == TRAINER_RIVAL_OAKS_LAB_SQUIRTLE)
return;
The intent was to skip drop logic during the intro rival fight. The problem: gTrainerBattleOpponent_A is never cleared after that battle. Every subsequent wild battle still reads 326 (the rival’s ID). The check always fires. The entire gear and streak block gets skipped for every wild encounter in the game.
The fix: wrap it in a trainer-type guard. Wild battles bypass the check entirely. Trainer fights run it only when appropriate. One condition, two words. Commit 98e25a5d9.
Root Cause Two: Firing in the Wrong Room
With wild battles fixed, gear dropped and streaks incremented. Still no bar on screen.
Added Lua diagnostics to track gMain.inBattle, CB2 function pointer, and palette fade state at every state transition. The output showed the bar completing — states 0 through 3 — while still inside BattleMainCB2. The battle hadn’t even finished returning to the field.
BattleMainCB1 runs first in the frame (our task creation), then BattleMainCB2 runs RunTasks() in the same frame. The gate checked gPaletteFade.active, which was false — the fade hadn’t started yet. Bar ran immediately. By the time the overworld loaded, the task was already destroyed.
Added gMain.inBattle to the gate. Bar now waits. Commit 689809594.
Root Cause Three: The Drop-Notify Screen
Better. Lua now showed the bar completing on a different wrong screen: the drop-notify callback (CB2_DropRun, address 0x0808ab81). Not the battle. Not the overworld. The intermediate screen that shows the gear drop animation.
inBattle was false. Palette fade was false. The gate passed. Bar ran on the wrong callback, completed, destroyed itself, and the overworld loaded to nothing.
Added IsOverworldActive() — a small helper in overworld.c that checks whether gMain.callback2 is CB2_Overworld. Changed the gate: bar only runs when the overworld is the active callback. Commit 58eb56d5c.
Root Cause Four: ResetTasks
Close now. Lua showed the task alive through the battle — but then gone before CB2_Overworld started. Traced the return path:
CB2_ReturnToField
→ CB2_ReturnToFieldLocal
→ ReturnToFieldLocal (case 0)
→ ResumeMap(FALSE)
→ ResetTasks() ← there it is
ResumeMap calls ResetTasks() as part of field initialization. The task gets wiped before the overworld ever starts. Both return paths — direct and through the drop-notify screen — route through here.
The fix: stop creating the task in HandleEndTurn_BattleWon. Set a flag instead — gBattleResultBarPending. In ResumeMap, check the flag right after ResetTasks() and recreate the task into clean task memory. Flag survives. Task does too. Commit 4d4474a13.
The Bar Finally Appears
Four root causes. One after the other, each one masked by the previous. The Lua output had been the guide the whole way — tracking state changes, callback transitions, address values. Without it this would have been weeks of blind guessing.
The bar appeared. Two problems immediately visible.
Rendering Bug One: The Permanent Frame
After the bar dismissed, the window frame border stayed on screen forever — an empty rectangle floating over the map.
The cause: DrawStdWindowFrame writes corner and edge tiles one tile outside the window’s declared bounds. ClearWindowTilemap only clears the window’s interior. The border tiles — at tilemapLeft-1, tilemapTop-1, width+2, height+2 — were never touched.
The right function is ClearStdWindowAndFrame, which calls WindowFunc_ClearStdWindowAndFrame and fills the full outer region with tile zero. One function swap in case 3. Commit 879371ab1.
Rendering Bug Two: The Garbled Map
Window appeared. The bottom half of the screen turned into horizontal stripes — repeating map tiles, corrupted layout.
GBA VRAM arithmetic: BG0 uses charBaseIndex=2 (tile data at 0x06008000) and mapBaseIndex=31 (tilemap at 0x0600F800). Tile N in charBase 2 lands at 0x06008000 + N×32. Tile 960 lands at exactly 0x0600F800 — where the tilemap lives.
The window was set to baseBlock=0x360 (864). 28×5 = 140 tiles. Last tile: 1003. Tiles 960–1003 overwrote 172 bytes of the live BG0 tilemap on every draw. The comment in the code said < 1024 limit — correct for charblock capacity, wrong for the real constraint.
Changed baseBlock to 0x140 (320). Last tile: 459. Well clear of 960, clear of every other concurrent overworld window. Commit 882f41ff9.
Rendering Bug Three: White on White
Window frame clean, map intact. Text invisible.
The window interior is filled with PIXEL_FILL(1) — palette index 1, which is white. The color arrays were using TEXT_COLOR_WHITE as the foreground. White ink on white paper.
Switched all four color arrays to TEXT_COLOR_DARK_GRAY foreground with TEXT_COLOR_WHITE background and TEXT_COLOR_LIGHT_GRAY shadow. Same formula the EMBER stats panel uses. Commit 1512c1856.
Two Small Things
Oak’s walk to the Pokédex table in the intro scene was two tiles short — he turned around before reaching the shelf. Two more walk_up steps in OakWalkToPokedexTable, matching return steps in OakReturnFromPokedexTable. Commit 023cc51b7.
Drop rates were running at 15% wild and 40% trainer. Route 1 handed out gear every few battles. At that rate the bag fills before the first gym and the whole loot loop loses tension. Pulled back to 10% wild and 25% trainer — frequent enough to feel rewarding, sparse enough that each piece matters. Commit fc939fe2c.
By the Numbers
| Metric | Value |
|---|---|
| Commits | 10 |
| Copilot requests | ~83 |
| Tool executions | ~1,120 |
| Sub-agents spawned | 6 |
Nine fixes to ship one feature. The tenth fix was writing this down.
Back to README