09 — Testing the Untestable
Building a debug menu for a platform that doesn’t believe in debugging
The Problem
I had a 5-tier endgame dungeon, legendary gear drops from gym leaders, and post-Champion content — but the feedback loop was slow. Playing from Pallet Town to the credits takes hours. Testing the Rift Zone meant beating the game first. Every time.
In professional software development, you write tests. On a GBA, there are no tests. There’s no console.log. There’s no debugger. You boot the ROM, play, and see what happens. The feedback loop for endgame content was brutal.
I needed a shortcut.
The Cheat Code
The solution was a classic: a secret button combo on the overworld. Hold L+R, press SELECT, and a debug menu appears. No menu option to clutter the UI. No accidental activation. Just a hidden tool for the developer.
The implementation hooks into ProcessPlayerFieldInput() — the function that handles all overworld button presses. It checks gMain.heldKeys for L+R (since the FieldInput struct doesn’t expose L/R as discrete fields) and triggers on SELECT:
if (input->pressedSelectButton
&& (gMain.heldKeys & L_BUTTON)
&& (gMain.heldKeys & R_BUTTON))
{
LockPlayerFieldControls();
OpenDebugWarpMenu();
return TRUE;
}
Five lines of code. The player can’t move while the menu is open, and it tears down cleanly when dismissed.
Two Pages, One Menu
The first version had 10 items — 4 warps and 6 cheats. It barely fit on a 160px screen using FONT_NORMAL at 16px per row. When I asked for warps to every town, that jumped to 18 items. 18 × 16 = 288 pixels. The GBA screen is 160 pixels tall.
I considered a scrollable ListMenu (the game has one), but it was heavy machinery for a debug tool. Instead: two pages with L/R switching. Page 1 is warps, Page 2 is cheats. The title bar shows which page you’re on. FONT_SMALL at 13px per row keeps everything tight.
Page 1 — Warps (12 destinations): All 11 Kanto towns plus the Rift Zone. Each warp drops you near the Pokémon Center. The Rift Zone warp auto-grants all 8 badges and sets the game-clear flag — without those, the Rift Warden won’t let you in.
Page 2 — Cheats (6 options):
- Grant All Badges — unlocks everything gate-checked by badges
- Set Game Clear — flags the Champion as defeated
- Give 99 Shards — both Gear Shards and Radiant Shards
- Party to Lv70 — sets every party member’s EXP to the level 70 threshold and recalculates stats
- Give Epic Gear — drops a Fierce Iron Fire Amulet and an Iron Lucky Flame Armor into the bag
- Cancel
The Level-Up Trick
Leveling the party wasn’t as simple as “set level = 70.” Pokémon don’t store their level directly — they store experience points, and the level is derived from the EXP value using species-specific growth rate tables:
u32 exp = gExperienceTables[gSpeciesInfo[species].growthRate][70];
SetMonData(&gPlayerParty[i], MON_DATA_EXP, &exp);
CalculateMonStats(&gPlayerParty[i]);
That last call — CalculateMonStats — is critical. Without it, the Pokémon has level 70 EXP but level 5 HP and stats. The game doesn’t lazily recalculate.
What Changed
The debug menu collapsed my testing loop from “play for 2 hours to reach endgame” to “press three buttons.” I could warp to Cinnabar, talk to the Rift Warden, enter the Rift Zone, fight a boss, check shard rewards, warp back to Viridian, test the merchant stock — all in under a minute.
This is the kind of tool that barely counts as a “feature.” No player will ever see it. But it made everything else possible to verify. Professional development instincts applied to a GBA: if you can’t test it quickly, you can’t iterate on it.
By the Numbers
| Metric | Value |
|---|---|
| Commits | ~12 |
| Copilot requests | ~32 |
| Tool executions | ~965 |
| Sub-agents | 42 |
Back to README